Zeeshan925

squid_acl_helper.py

Dec 16th, 2025
10
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 9.33 KB | None | 0 0
  1. #!/usr/local/bin/python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Copyright (c) 2023 Ad Schellevis <[email protected]>
  5. All rights reserved.
  6.  
  7. Redistribution and use in source and binary forms, with or without
  8. modification, are permitted provided that the following conditions are met:
  9.  
  10. 1. Redistributions of source code must retain the above copyright notice,
  11. this list of conditions and the following disclaimer.
  12.  
  13. 2. Redistributions in binary form must reproduce the above copyright
  14. notice, this list of conditions and the following disclaimer in the
  15. documentation and/or other materials provided with the distribution.
  16.  
  17. THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
  18. INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
  19. AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
  20. AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
  21. OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
  22. SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
  23. INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
  24. CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
  25. ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
  26. POSSIBILITY OF SUCH DAMAGE.
  27. """
  28. import argparse
  29. import decimal
  30. import sys
  31. import syslog
  32. import traceback
  33. from urllib.parse import urlparse
  34. import redis
  35. import ujson
  36. import ipaddress
  37.  
  38.  
  39. class RedisAuth:
  40. def __init__(self, host, port):
  41. self._redis = redis.Redis(host=host, port=port, db=0)
  42.  
  43. def domain_policy_iterator(self, r_fqdn):
  44. try:
  45. tmp = self._redis.get("domain:%s" % r_fqdn)
  46. if tmp:
  47. domain_policy = ujson.loads(tmp.decode())
  48. else:
  49. return
  50. except Exception:
  51. syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' '))
  52. return
  53.  
  54. if type(domain_policy.get('items', None)) is list:
  55. for policy in domain_policy['items']:
  56. if type(policy) is dict:
  57. for fieldname in ['id', 'path', 'wildcard', 'action', 'applies_on', 'source_net']:
  58. if fieldname not in policy:
  59. policy[fieldname] = None
  60. yield policy
  61.  
  62. def get_user(self, uid):
  63. if uid == "-":
  64. return {'applies_on': set('-')}
  65. try:
  66. tmp = self._redis.get("user:%s" % uid)
  67. if not tmp:
  68. return None
  69. udata = ujson.loads(tmp.decode())
  70. udata['applies_on'] = set(udata['applies_on']) if 'applies_on' in udata else set()
  71. except Exception:
  72. syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' '))
  73. return None
  74.  
  75. return udata
  76.  
  77. def in_network(src, networks):
  78. if networks is None or type(networks) is not list or src == '-':
  79. return True
  80. try:
  81. src_net = ipaddress.ip_network(src)
  82. except ValueError:
  83. syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' '))
  84. return False
  85. for network in networks:
  86. try:
  87. if src_net.overlaps(ipaddress.ip_network(network)):
  88. return True
  89. except ValueError:
  90. syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' '))
  91. return False
  92.  
  93. def match_policy(acl, ident, src, method, uri, sslurlonly=False):
  94. match_res = {'message': "ERR message=\"no (valid) IDENT %s\"\n" % ident}
  95. if uri.find('://') == -1:
  96. base_domain = uri.split(':')[0]
  97. request_path = '/'
  98. else:
  99. uri_parsed = urlparse(uri)
  100. base_domain = uri_parsed.netloc.split(':')[0]
  101. request_path = uri_parsed.path if uri_parsed.path else '/'
  102.  
  103. syslog.syslog(
  104. syslog.LOG_NOTICE,
  105. "ACL-REQ |%s| |%s| |%s| |%s| |%s| %s" % (acl, ident, src, method, uri, 'SNI only' if sslurlonly else '')
  106. )
  107. fqdn = base_domain
  108. user_data = redis_auth.get_user(ident)
  109. if user_data:
  110. acl_decisions = dict()
  111. while len(acl_decisions) == 0:
  112. for this_policy in redis_auth.domain_policy_iterator(fqdn):
  113. is_parent = base_domain != fqdn
  114. match_parent = this_policy['path'] == '/' and is_parent and this_policy['wildcard']
  115. match_main = request_path.find(this_policy['path']) == 0 and not is_parent
  116. if (match_parent or match_main) and set(this_policy['applies_on']) & user_data['applies_on']:
  117. if not in_network(src, this_policy['source_net']):
  118. continue
  119. tp = 0 if this_policy['action'] == 'deny' else 1
  120. this_prio = decimal.Decimal("%d.%d" % (len(this_policy['path']), tp))
  121. acl_decisions[this_prio] = this_policy
  122. acl_decisions[this_prio]['domain'] = fqdn
  123.  
  124. if fqdn.find('.') == -1:
  125. if fqdn == '*':
  126. break
  127. else:
  128. fqdn = '*'
  129. else:
  130. fqdn = fqdn.split('.', maxsplit=1)[1]
  131.  
  132. match_res['user'] = user_data
  133. match_res['user']['applies_on'] = list(user_data['applies_on'])
  134.  
  135. # PATCHED: enforce HTTPS (CONNECT) block for network-only users
  136. if method.lower() == 'connect':
  137. if len(acl_decisions) > 0:
  138. acl_decision = acl_decisions[sorted(acl_decisions.keys(), reverse=True)[0]]
  139. match_res['policy'] = acl_decision
  140. if acl_decision['action'] == 'deny':
  141. match_res['message'] = f'ERR message="reason:{acl_decision["id"]} policy_type:{acl_decision["policy_type"]}" user="{ident}"\n'
  142. else:
  143. match_res['message'] = f'OK message="whitelisted {acl_decision["id"]}" user="{ident}"\n'
  144. else:
  145. if ident == '-':
  146. match_res['policy'] = {'action': 'deny', 'policy_type': 'fallback'}
  147. match_res['message'] = f'ERR message="network-only users cannot access HTTPS" user="{ident}"\n'
  148. else:
  149. match_res['policy'] = {'action': 'allow', 'policy_type': 'fallback'}
  150. match_res['message'] = f'OK user="{ident}"\n'
  151. else:
  152. if len(acl_decisions) > 0:
  153. acl_decision = acl_decisions[sorted(acl_decisions.keys(), reverse=True)[0]]
  154. match_res['policy'] = acl_decision
  155. if acl_decision['action'] == 'deny':
  156. match_res['message'] = "ERR message=\"reason:%s policy_type:%s\" user=\"%s\"\n" % (
  157. acl_decision['id'], acl_decision['policy_type'], ident
  158. )
  159. else:
  160. match_res['message'] = "OK message=\"whitelisted %s\" user=\"%s\"\n" % (acl_decision['id'], ident)
  161. elif ident != '-':
  162. match_res['policy'] = {'action': 'allow', 'policy_type': 'fallback'}
  163. match_res['message'] = "OK user=\"%s\"\n" % ident
  164.  
  165. return match_res
  166.  
  167.  
  168. if __name__ == '__main__':
  169. parser = argparse.ArgumentParser()
  170. parser.add_argument('--test_user', help='test mode (singleshot), username')
  171. parser.add_argument('--test_uri', help='test mode (singleshot), uri')
  172. parser.add_argument('--test_src', help='test mode (singleshot), source address', default='-')
  173. parser.add_argument('--redis_host', help='redis hostname (default: 127.0.0.1)', default='127.0.0.1')
  174. parser.add_argument('--redis_port', help='redis port number (default: 6379)', type=int, default=6379)
  175. parser.add_argument('--sslurlonly', help='Log SNI information only enabled', action="store_true", default=False)
  176. parser.add_argument(
  177. '--no_ident',
  178. help='Do not expect iden/user information in the message line',
  179. action="store_true",
  180. default=False
  181. )
  182.  
  183. args = parser.parse_args()
  184. syslog.openlog('squid', facility=syslog.LOG_LOCAL2)
  185. redis_auth = RedisAuth(args.redis_host, args.redis_port)
  186. if args.test_user and args.test_uri:
  187. result = match_policy(acl='-', ident=args.test_user, src=args.test_src, method='-', uri=args.test_uri)
  188. print(ujson.dumps(result))
  189. else:
  190. while True:
  191. try:
  192. line = sys.stdin.readline().strip()
  193. if line == "":
  194. sys.exit()
  195. if line:
  196. try:
  197. acl_parts = line.split()
  198. except ValueError:
  199. sys.stdout.write("ERR message=\"missing input\"\n")
  200. break
  201. offset = -1 if args.no_ident else 0
  202. result = match_policy(
  203. acl=acl_parts[0],
  204. ident='-' if args.no_ident else acl_parts[1],
  205. src=acl_parts[2+offset],
  206. method=acl_parts[3+offset],
  207. uri=acl_parts[4+offset],
  208. sslurlonly=args.sslurlonly
  209. )
  210. sys.stdout.write(result['message'])
  211. sys.stdout.flush()
  212. except IOError:
  213. pass
  214.  
Advertisement
Add Comment
Please, Sign In to add comment