analytics-platform 0.3.9__tar.gz → 0.3.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/PKG-INFO +1 -1
  2. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/__init__.py +2 -1
  3. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/core.py +19 -1
  4. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/firewall.py +23 -12
  5. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/forms.py +33 -2
  6. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/proxy.py +21 -2
  7. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/queries.py +20 -3
  8. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/request_rules.py +53 -15
  9. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/PKG-INFO +1 -1
  10. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/pyproject.toml +1 -1
  11. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_firewall.py +50 -0
  12. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_hardening.py +23 -0
  13. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_proxy.py +13 -0
  14. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_request_rules.py +49 -0
  15. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/LICENSE +0 -0
  16. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/README.md +0 -0
  17. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/_env.py +0 -0
  18. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/analytics.py +0 -0
  19. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/audit.py +0 -0
  20. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/calls.py +0 -0
  21. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/celery.py +0 -0
  22. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/challenge.py +0 -0
  23. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/crawlers.py +0 -0
  24. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/django.py +0 -0
  25. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/dramatiq.py +0 -0
  26. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/errors.py +0 -0
  27. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/flags.py +0 -0
  28. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/inventory.py +0 -0
  29. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/maintenance.py +0 -0
  30. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/performance.py +0 -0
  31. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/posture.py +0 -0
  32. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/queues.py +0 -0
  33. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/resources.py +0 -0
  34. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/rq.py +0 -0
  35. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/start.py +0 -0
  36. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/SOURCES.txt +0 -0
  37. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/dependency_links.txt +0 -0
  38. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/top_level.txt +0 -0
  39. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/setup.cfg +0 -0
  40. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_calls_queues.py +0 -0
  41. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_celery.py +0 -0
  42. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_challenge.py +0 -0
  43. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_checkin.py +0 -0
  44. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_crawlers.py +0 -0
  45. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_errors.py +0 -0
  46. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_headers.py +0 -0
  47. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_limits.py +0 -0
  48. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_page_limits.py +0 -0
  49. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_proxy_answer.py +0 -0
  50. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_queries.py +0 -0
  51. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_redis.py +0 -0
  52. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_resources.py +0 -0
  53. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_rq_dramatiq.py +0 -0
  54. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_start.py +0 -0
  55. {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_static_late.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.9
3
+ Version: 0.3.11
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -453,7 +453,8 @@ class Platform:
453
453
  try:
454
454
  ip = firewall.client_ip(remote, headers)
455
455
  decision = firewall.check(ip, method, path, header(headers, "user-agent"),
456
- header(headers, "accept"), header(headers, "cookie"))
456
+ header(headers, "accept"), header(headers, "cookie"),
457
+ fetch_site=header(headers, "sec-fetch-site"))
457
458
  if decision["action"] == "block" and decision["rule"] == "challenge":
458
459
  status, extra, body = firewall.challenge_page(ip, method)
459
460
  return status, body, extra
@@ -23,7 +23,7 @@ import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
25
 
26
- VERSION = "0.3.9"
26
+ VERSION = "0.3.11"
27
27
  TIMEOUT = 5
28
28
  MIN_SYNC = 30
29
29
  RETRY = 60
@@ -447,6 +447,24 @@ def header(headers, name):
447
447
  _PATH_SAFE = "/:@!$&'()*+,;=-._~"
448
448
 
449
449
 
450
+ # A scheme, then the authority only when "//" follows it (firewall.md, "Matching").
451
+ _ABSOLUTE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:(?://[^/?#]*)?")
452
+
453
+
454
+ def origin_form(target):
455
+ """The request target as the app routes it: an absolute-form one ("GET
456
+ http://host/admin", sent as if to a proxy) is routed by its path, so it is
457
+ cut to "/admin"; matched as written it began "http:" and missed every rule
458
+ on "/admin". gunicorn's urlsplit reads "x:/admin" and "x:admin" as
459
+ "/admin" too, with no "//" at all, so the scheme goes whatever follows it.
460
+ "*" and a target already "/..." are kept as they are."""
461
+ text = str(target or "")
462
+ if not text or text.startswith("/") or text == "*":
463
+ return text
464
+ rest = _ABSOLUTE.sub("", text, count=1)
465
+ return rest if rest.startswith("/") else "/" + rest
466
+
467
+
450
468
  def request_target(path, query="", raw=None):
451
469
  """The request target as the visitor sent it, query string and all, which
452
470
  is what the request rules match (sdk/protocols/firewall.md): the Node and
@@ -19,7 +19,7 @@ import time
19
19
  from datetime import datetime, timezone
20
20
 
21
21
  from . import challenge as browser_check
22
- from .core import Periodic, RETRY, header
22
+ from .core import Periodic, RETRY, header, origin_form
23
23
  from .request_rules import MAX_TEXT, _decode_once, compile_limits, compile_rules, match_limits, match_request, normalize_target, prepare
24
24
 
25
25
  PROTOCOL = 1
@@ -139,10 +139,9 @@ def is_static(path, method="GET"):
139
139
  be a file: one with a "." or ".." segment, a ";", a "\\", an encoded "/"
140
140
  or a "%xx" left after decoding is a page, so "/static/../login",
141
141
  "/static%2F..%2Flogin", "/login;x.css" and "/login%3Bx.css" all count. A
142
- browser asks for its files with none of those. Nor is a path past the
143
- rules' 2,000 characters: cut there, "/search/xxx...x.css/more" would end
144
- in .css and flood a catch-all route past the limits. Only GET and HEAD
145
- fetch a file."""
142
+ browser asks for its files with none of those. Nor is a path over 2,000
143
+ characters: no asset has a name that long. Only GET and HEAD fetch a
144
+ file."""
146
145
  if (method or "") not in _FETCHES:
147
146
  return False
148
147
  raw = re.split(r"[?#]", str(path or ""), 1)[0]
@@ -566,12 +565,18 @@ class Firewall:
566
565
  self.flush()
567
566
  return FLUSH_EVERY
568
567
 
569
- def check(self, ip, method="", path="", user_agent="", accept="", cookie="", now=None):
568
+ def check(self, ip, method="", path="", user_agent="", accept="", cookie="", now=None, fetch_site=""):
570
569
  """-> {"action": "allow" | "block" | "monitor", "rule": ..., "status": ...}.
571
570
  Allowed addresses skip every other rule. accept and cookie (the
572
571
  request's headers) are for the browser check: rule "challenge",
573
- status 503, answered with challenge_page()."""
572
+ status 503, answered with challenge_page(). fetch_site: the request's
573
+ Sec-Fetch-Site header."""
574
574
  allow = {"action": "allow", "rule": "", "status": 200}
575
+ path = origin_form(path)
576
+ # Another site made the browser send this (an <img> of /.env, a
577
+ # page asked for 120 times a minute), from the visitor's own address:
578
+ # refused as ever, but nobody is banned for it, here or elsewhere.
579
+ foreign = str(fetch_site or "").strip().lower() in ("cross-site", "same-site")
575
580
  rules = self.rules
576
581
  if not rules or not rules["enabled"]:
577
582
  return allow
@@ -583,7 +588,7 @@ class Firewall:
583
588
  if not rule and is_probe(path, rules["probes"]):
584
589
  rule = "probe"
585
590
  # Asking for a secret file: banned here in block mode; the report bans it on the other servers.
586
- if action == "block":
591
+ if action == "block" and not foreign:
587
592
  self._ban_here(str(ip), rules["probes"]["ban"])
588
593
  matched = None
589
594
  limits = rules.get("limits")
@@ -597,7 +602,7 @@ class Firewall:
597
602
  # A rule the dashboard only watches passes even in block mode;
598
603
  # one that blocks also bans the address here, as a flood does.
599
604
  action = "block" if action == "block" and matched["action"] == "block" else "monitor"
600
- if action == "block" and matched["ban"]:
605
+ if action == "block" and matched["ban"] and not foreign:
601
606
  self._ban_here(str(ip), matched["ban"])
602
607
  # A static file isn't counted toward the page limits or the rate,
603
608
  # only compared with the counts so far: an address already over them
@@ -625,11 +630,16 @@ class Firewall:
625
630
  and self._over_rate(str(ip), rules["rate"], peek=static)):
626
631
  rule = "rate"
627
632
  # Banned here straight away in block mode, as a probe is; the report bans it on the other servers.
628
- if action == "block":
633
+ if action == "block" and not foreign:
629
634
  self._ban_here(str(ip), rules["ban"])
630
635
  def event(name, act):
636
+ # Reported as only watched, so the collector bans it on no other
637
+ # server and counts nothing toward `shared`.
638
+ if foreign and act == "block" and (name in ("probe", "rate") or name.startswith("request:")):
639
+ act = "monitor"
631
640
  return {"ip": str(ip), "rule": name, "action": act,
632
- "method": str(method)[:10], "path": str(path).split("?")[0][:300], "user_agent": str(user_agent)[:300]}
641
+ "method": str(method)[:10], "path": re.split(r"[?#]", str(path), 1)[0][:300],
642
+ "user_agent": str(user_agent)[:300]}
633
643
 
634
644
  # A watched page limit is reported whatever comes after it: the rate
635
645
  # limit or the browser check deciding doesn't hide it.
@@ -694,6 +704,7 @@ class Firewall:
694
704
  beacons are never rate-limited, checked or matched against request
695
705
  rules. -> the same shape as check()."""
696
706
  allow = {"action": "allow", "rule": "", "status": 200}
707
+ path = origin_form(path)
697
708
  rules = self.rules
698
709
  if not rules or not rules["enabled"]:
699
710
  return allow
@@ -705,7 +716,7 @@ class Firewall:
705
716
  return allow
706
717
  action = "block" if (self.mode or rules["mode"]) == "block" else "monitor"
707
718
  self._report({"ip": str(ip), "rule": rule, "action": action, "method": str(method)[:10],
708
- "path": str(path).split("?")[0][:300], "user_agent": str(user_agent)[:300]})
719
+ "path": re.split(r"[?#]", str(path), 1)[0][:300], "user_agent": str(user_agent)[:300]})
709
720
  return {"action": action, "rule": rule, "status": 403}
710
721
 
711
722
  def _needs_check(self, rules, key, ip, method, path, accept, cookie, now):
@@ -8,7 +8,20 @@ is accepted (checked False).
8
8
  """
9
9
  import re
10
10
 
11
- PRIVATE_FIELD = re.compile(r"pass|card|cvc|cvv|secret|token|ssn|iban|csrf|analytics-shield", re.I)
11
+ # Fields never sent, by name. check() is handed the whole form, so whoever
12
+ # protects a sign-up or a checkout relies on this list: keep it wide, and the
13
+ # same as platform-php's Forms.php, with "-" as well as "_" between words: the
14
+ # browser's own autofill names are "cc-number", "cc-csc" and "cc-exp".
15
+ # ("pass" also covers passwd and passport.)
16
+ PRIVATE_FIELD = re.compile(
17
+ r"pass|pwd|card|cc[-_]?(num|no|number|csc|exp)|cvc|cvv|cvn|expiry|exp[-_]?(month|year|date)|secret|token"
18
+ r"|\bauth\b|authoriz|api[-_]?key|otp|mfa|2fa|\bpin\b|security[-_]?code|signature|private|ssn|sin\b|nino"
19
+ r"|tax[-_]?id|iban|bic|swift|routing|account[-_]?(no|num|number)|dob|birth|licen[cs]e|csrf|nonce"
20
+ r"|analytics-shield", re.I | re.A)
21
+ # A card number in a field nobody thought to name "card" still must not be
22
+ # sent, so values are checked too: 13-19 digits that pass Luhn are taken out
23
+ # of the text and the rest of the message is kept, so spam is still judged.
24
+ DIGITS = re.compile(r"\b(?:\d[ -]?){12,18}\d\b", re.A)
12
25
  MAX_FIELDS = 50
13
26
  ANSWER = "analytics-shield"
14
27
 
@@ -20,11 +33,29 @@ def plain_fields(fields):
20
33
  continue
21
34
  if isinstance(value, (list, tuple)):
22
35
  value = ", ".join(v for v in value if isinstance(v, str))
36
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
37
+ value = str(value)
23
38
  if isinstance(value, str):
24
- out[str(label)[:100]] = value[:2000]
39
+ out[str(label)[:100]] = without_card_numbers(value[:2000])
25
40
  return out
26
41
 
27
42
 
43
+ def _luhn(digits):
44
+ total = 0
45
+ for index, char in enumerate(reversed(digits)):
46
+ n = int(char)
47
+ if index % 2:
48
+ n = n * 2 - 9 if n > 4 else n * 2
49
+ total += n
50
+ return total % 10 == 0
51
+
52
+
53
+ def without_card_numbers(text):
54
+ """Anything shaped like a card number, and passing Luhn, as [removed].
55
+ Luhn is what keeps order numbers and phone numbers readable."""
56
+ return DIGITS.sub(lambda m: "[removed]" if _luhn(re.sub(r"\D", "", m.group(0))) else m.group(0), str(text))
57
+
58
+
28
59
  class Forms:
29
60
  def __init__(self, core, **_):
30
61
  self.core = core
@@ -18,6 +18,7 @@ It answers before every other module: a visitor's beacon is our own traffic,
18
18
  not a page of the site, so it shouldn't be counted, timed or rate-limited.
19
19
  """
20
20
  import hashlib
21
+ import http.client
21
22
  import re
22
23
  import threading
23
24
  import time
@@ -56,6 +57,15 @@ FILE_PATH = re.compile(r"v(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)\Z")
56
57
  # own site. Shared cases: sdk/protocols/proxy-answer-cases.json.
57
58
  ANSWER_TYPES = frozenset(("application/json", "text/plain", "image/webp"))
58
59
  _VISIBLE = re.compile(r"[\x20-\x7e]*")
60
+ # A query a URL can carry as it is. The visitor writes it, and http.client
61
+ # refuses a space, a control character or a byte past 0x7f before it
62
+ # connects: forwarded, that would count against the collector and stop
63
+ # every visitor's beacons (the breaker).
64
+ QUERY = re.compile(r"[\x21-\x7e]*\Z")
65
+ # What says the collector itself failed: no connection, a timeout, a broken
66
+ # answer. Anything else (a ValueError from text http.client won't send) is
67
+ # this request's own fault, never the collector's.
68
+ UNREACHABLE = (OSError, http.client.HTTPException)
59
69
 
60
70
 
61
71
  def answer_type(value):
@@ -193,10 +203,13 @@ class Proxy:
193
203
  # The collector answered: only its own failures count against it.
194
204
  self._broke() if answer.code >= 500 else self._worked()
195
205
  self._remember_failure(rest)
196
- except Exception as error:
206
+ except UNREACHABLE as error:
197
207
  self._broke()
198
208
  self._remember_failure(rest)
199
209
  self.core.log("proxy", "could not fetch %s: %s" % (rest, error))
210
+ except Exception as error:
211
+ self._remember_failure(rest)
212
+ self.core.log("proxy", "could not fetch %s: %s" % (rest, error))
200
213
  # Upstream is unreachable: an old copy beats no tracker at all.
201
214
  if body is None and held:
202
215
  body = held[0]
@@ -220,6 +233,9 @@ class Proxy:
220
233
  return NOT_FOUND
221
234
  if body is not None and len(body) > MAX_BODY:
222
235
  return 413, [], b""
236
+ query = query.decode("latin-1") if isinstance(query, bytes) else str(query or "")
237
+ if not QUERY.match(query):
238
+ return 400, [("Content-Type", "text/plain; charset=utf-8")], b"Bad request"
223
239
  # The visitor's address: the collector needs it for the country, the
224
240
  # daily visitor ID and the per-visitor rate limit.
225
241
  out = {"X-Forwarded-For": client_ip or ""}
@@ -243,11 +259,14 @@ class Proxy:
243
259
  except urllib.error.HTTPError as answer:
244
260
  self._broke() if answer.code >= 500 else self._worked()
245
261
  return answer.code, self._answer_headers(answer.headers), answer.read(MAX_BODY)
246
- except Exception as error:
262
+ except UNREACHABLE as error:
247
263
  # The tracker already treats a failed beacon as lost and carries on.
248
264
  self._broke()
249
265
  self.core.log("proxy", "could not reach the collector: %s" % error)
250
266
  return 502, [], b""
267
+ except Exception as error:
268
+ self.core.log("proxy", "could not send a beacon on: %s" % error)
269
+ return 400, [], b""
251
270
 
252
271
  @staticmethod
253
272
  def _answer_headers(headers):
@@ -89,6 +89,20 @@ _NAME = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz012345678
89
89
  _PLACEHOLDER = r"(?:\?|\$[0-9]+|:[A-Za-z0-9_]+|@[A-Za-z0-9_]+)"
90
90
  _LIST = re.compile(r"\(%s*%s(?:%s*,%s*%s)*%s*\)" % (_S, _PLACEHOLDER, _S, _S, _PLACEHOLDER, _S))
91
91
  _ROWS = re.compile(r"\(\?\)(?:%s*,%s*\(\?\))+" % (_S, _S))
92
+ # Where "..." may hold a value rather than a name: SQLite takes a
93
+ # double-quoted string as a literal when no column has that name (SQLCipher's
94
+ # PRAGMA key = "x'2DD2..'"), Oracle writes a password as a quoted name, and an
95
+ # unknown database may do either. There "..." is kept only when it reads as a
96
+ # plain name, closed: "email", never "x'2D..'", "N3w P@ss" or an unclosed one.
97
+ _VALUE_QUOTES = frozenset(("sqlite", "oracle", "other"))
98
+ _QUOTED_NAME = re.compile(r'"[A-Za-z_][A-Za-z0-9_$#]{0,127}"')
99
+ # A password or key written as a name, on any database: Oracle's
100
+ # ALTER USER u IDENTIFIED BY pw and SQLCipher's PRAGMA key = "passphrase".
101
+ # What follows is "?" whether it was a word or a quoted name. ASCII flags, as
102
+ # JavaScript's \b and case-insensitive matching are.
103
+ _SECRET_NAME = r'("(?:[^"]|"")*"?|`(?:[^`]|``)*`?|[^%s;,()]+)' % re.escape(_SPACE)
104
+ _IDENTIFIED = re.compile(r"(\bIDENTIFIED BY )" + _SECRET_NAME, re.I | re.A)
105
+ _PRAGMA_KEY = re.compile(r"(\bPRAGMA (?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:HEX)?(?:RE)?KEY ?[=(] ?)" + _SECRET_NAME, re.I | re.A)
92
106
  _MONGO = re.compile(r"([^%s]+)(?: ([^%s]+))?(?: (.*))?\Z" % (re.escape(_SPACE), re.escape(_SPACE)), re.S)
93
107
  _INDEX_KEY = re.compile(r"(?:0|[1-9][0-9]*)\Z")
94
108
  _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
@@ -178,8 +192,11 @@ def _sql_shape(sql, db, backslash_literal=None):
178
192
  last = "?"
179
193
  elif c == '"' or c == "`":
180
194
  end = _quote_end(sql, i, c, not literal)
181
- out.append(sql[i:end])
182
- last = sql[end - 1]
195
+ run = sql[i:end]
196
+ if c == '"' and db in _VALUE_QUOTES and not _QUOTED_NAME.fullmatch(run):
197
+ run = "?"
198
+ out.append(run)
199
+ last = run[-1]
183
200
  i = end
184
201
  elif c == "$" and prev not in _WORD and _DOLLAR_TAG.match(sql[i:i + 64]):
185
202
  tag = _DOLLAR_TAG.match(sql[i:i + 64]).group(0)
@@ -207,7 +224,7 @@ def _sql_shape(sql, db, backslash_literal=None):
207
224
  out.append(c)
208
225
  last = c
209
226
  i += 1
210
- text = "".join(out)
227
+ text = _PRAGMA_KEY.sub(r"\g<1>?", _IDENTIFIED.sub(r"\g<1>?", "".join(out)))
211
228
  return _ROWS.sub("(?)", _LIST.sub("(?)", text)).strip(_SPACE)
212
229
 
213
230
 
@@ -13,10 +13,15 @@ import re
13
13
  from urllib.parse import unquote_plus
14
14
 
15
15
  MAX_RULES = 200
16
- # How much of each part a rule may look at. Patterns come from our rules, but
17
- # the text comes from whoever is asking, and `re` can't be timed out: a bounded
18
- # slice keeps a bad pattern from being a hung request.
16
+ # How much of a part a regex looks at at once. Patterns come from our rules,
17
+ # but the text comes from whoever is asking, and `re` can't be timed out: a
18
+ # bounded window keeps a bad pattern from being a hung request. The plain
19
+ # matchers cost one pass, so they read the whole part: cut, 2,000 characters
20
+ # of filler would carry an attack past every rule.
19
21
  MAX_TEXT = 2000
22
+ # Each further window starts this much later, so a match up to this long is
23
+ # found wherever it sits.
24
+ STEP = MAX_TEXT // 2
20
25
  MAX_REGEX = 200
21
26
  SECTIONS = ("path", "query", "ua")
22
27
  # The only kinds of group a pattern may open with "(?": non-capturing and lookahead.
@@ -178,10 +183,15 @@ def _section(section, regex=True):
178
183
  pattern = section.get("regex") if regex else None
179
184
  if safe_pattern(pattern):
180
185
  try:
181
- out["regex"] = re.compile(pattern, re.IGNORECASE)
186
+ compiled = re.compile(pattern, re.IGNORECASE)
182
187
  except re.error:
183
188
  # A pattern we can't compile is dropped; the rule's other parts stay.
184
- pass
189
+ compiled = None
190
+ if compiled is not None:
191
+ out["regex"] = compiled
192
+ # "$" ends the pattern unless it is escaped: an odd run of "\" before it.
193
+ dollar = pattern.endswith("$") and (len(pattern) - len(pattern[:-1].rstrip("\\")) - 1) % 2 == 0
194
+ out["anchors"] = (pattern.startswith("^"), dollar)
185
195
  return out or None
186
196
 
187
197
 
@@ -255,9 +265,32 @@ def _router_path(path):
255
265
 
256
266
  def normalize_target(target):
257
267
  """The path of a request target (no query string) as the router reads it:
258
- what the probes, the static-file check and the rules' path also match."""
268
+ what the probes, the static-file check and the rules' path also match.
269
+ Resolved from the whole path: "/./././.../login" is "/login" to a router
270
+ however many "./" it carries."""
259
271
  text = str(target or "")
260
- return _router_path(re.split(r"[?#]", text, 1)[0][:MAX_TEXT])[:MAX_TEXT]
272
+ return _router_path(re.split(r"[?#]", text, 1)[0])
273
+
274
+
275
+ def _search(regex, anchors, text):
276
+ """A regex over the part in windows of MAX_TEXT, each STEP after the last,
277
+ until one reaches the end. A pattern that begins with "^" is tried only in
278
+ the window that starts the part, one that ends with "$" only in the one
279
+ that ends it: a window's edges are not the part's."""
280
+ if len(text) <= MAX_TEXT:
281
+ return regex.search(text) is not None
282
+ head, tail = anchors
283
+ if head and tail:
284
+ return False
285
+ if head:
286
+ return regex.search(text[:MAX_TEXT]) is not None
287
+ last = max(0, -(-(len(text) - MAX_TEXT) // STEP)) * STEP
288
+ if tail:
289
+ return regex.search(text[last:]) is not None
290
+ for start in range(0, last + 1, STEP):
291
+ if regex.search(text[start:start + MAX_TEXT]):
292
+ return True
293
+ return False
261
294
 
262
295
 
263
296
  def _hits(section, raw, decoded, routed=None):
@@ -275,7 +308,7 @@ def _hits(section, raw, decoded, routed=None):
275
308
  if any(entry and entry in text for entry in section.get("contains", ())):
276
309
  return True
277
310
  regex = section.get("regex")
278
- if regex is not None and regex.search(text):
311
+ if regex is not None and _search(regex, section["anchors"], text):
279
312
  return True
280
313
  return False
281
314
 
@@ -284,23 +317,28 @@ def prepare(method="", path="", user_agent=""):
284
317
  """The request as every rule and page limit reads it, worked out once.
285
318
  `path` is the request target as the server received it, query string and
286
319
  all. The firewall prepares a request once and hands the result to both
287
- match_request and match_limits, so the path is decoded and routed once."""
320
+ match_request and match_limits, so the path is decoded and routed once.
321
+ A "#" ends the path and the query: a raw client can send one, and every
322
+ router drops it, so "POST /login#1" runs the login."""
288
323
  target = str(path or "")
324
+ end = target.find("#")
325
+ if end != -1:
326
+ target = target[:end]
289
327
  mark = target.find("?")
290
- bare = (target if mark == -1 else target[:mark])[:MAX_TEXT]
291
- query = ("" if mark == -1 else target[mark + 1:])[:MAX_TEXT]
328
+ bare = target if mark == -1 else target[:mark]
329
+ query = "" if mark == -1 else target[mark + 1:]
292
330
  return {
293
331
  "method": str(method or "").upper(),
294
332
  "raw_path": _normalize_path(bare),
295
- "decoded_path": _normalize_path(_decode_once(bare)[:MAX_TEXT]),
333
+ "decoded_path": _normalize_path(_decode_once(bare)),
296
334
  # And as the router reads it: "/x;y/../admin" is "/admin" there, so a
297
335
  # rule on "/admin" catches it; a hit on any of the three counts.
298
- "routed_path": _router_path(bare)[:MAX_TEXT],
336
+ "routed_path": _router_path(bare),
299
337
  "raw_query": query.lower(),
300
338
  # Lower-cased after decoding: %55NION decodes to "UNION", which a
301
339
  # lower-cased "union select" rule would otherwise never see.
302
- "decoded_query": _decode_once(query)[:MAX_TEXT].lower(),
303
- "ua": str(user_agent or "")[:MAX_TEXT].lower(),
340
+ "decoded_query": _decode_once(query).lower(),
341
+ "ua": str(user_agent or "").lower(),
304
342
  }
305
343
 
306
344
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.9
3
+ Version: 0.3.11
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -10,7 +10,7 @@ Security = "https://sitegoose.com/security"
10
10
 
11
11
  [project]
12
12
  name = "analytics-platform"
13
- version = "0.3.9"
13
+ version = "0.3.11"
14
14
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.8"
@@ -42,6 +42,56 @@ class RateBan(unittest.TestCase):
42
42
  self.assertEqual(rules, ["", "", "rate", "ban"])
43
43
 
44
44
 
45
+ class CrossSite(unittest.TestCase):
46
+ """A request another site made the browser send comes from the visitor's
47
+ own address: refused, but nobody is banned for it here or elsewhere."""
48
+
49
+ def wall(self):
50
+ wall = Firewall(FakeCore())
51
+ wall.rules = wall._compile({
52
+ "v": 1, "enabled": True, "mode": "block", "rate": {"limit": 2, "window": 60}, "ban": {"seconds": 600},
53
+ "probes": {"exact": ["/.env"], "ban": 600},
54
+ "requests": {"rules": [{"id": "shell", "action": "block", "ban": 600, "path": {"exact": ["/shell.php"]}}]},
55
+ })
56
+ return wall
57
+
58
+ def actions(self, wall):
59
+ return {row["rule"]: row["action"] for row in wall.queue.values()}
60
+
61
+ def test_a_probe_or_a_rule_is_refused_but_bans_nobody(self):
62
+ for value in ("cross-site", "Same-Site"):
63
+ wall = self.wall()
64
+ for path, rule in (("/.env", "probe"), ("/shell.php", "request:shell")):
65
+ decision = wall.check("203.0.113.9", "GET", path, fetch_site=value)
66
+ self.assertEqual((decision["action"], decision["rule"], decision["status"]), ("block", rule, 403))
67
+ self.assertEqual(wall.bans, {}, value)
68
+ self.assertEqual(self.actions(wall), {"probe": "monitor", "request:shell": "monitor"})
69
+ self.assertEqual(wall.check("203.0.113.9", "GET", "/")["rule"], "", "not banned afterwards")
70
+
71
+ def test_the_rate_is_answered_but_bans_nobody(self):
72
+ wall = self.wall()
73
+ rules = [wall.check("203.0.113.9", "GET", "/", fetch_site="cross-site")["rule"] for _ in range(4)]
74
+ self.assertEqual(rules, ["", "", "rate", "rate"])
75
+ self.assertEqual(wall.bans, {})
76
+ self.assertEqual(self.actions(wall), {"rate": "monitor"})
77
+
78
+ def test_same_origin_or_no_header_bans_as_before(self):
79
+ for value in ("same-origin", "none", ""):
80
+ wall = self.wall()
81
+ self.assertEqual(wall.check("203.0.113.9", "GET", "/.env", fetch_site=value)["rule"], "probe")
82
+ self.assertEqual(self.actions(wall), {"probe": "block"})
83
+ self.assertEqual(wall.check("203.0.113.9", "GET", "/")["rule"], "ban", value)
84
+
85
+ def test_every_adapter_hands_the_header_over(self):
86
+ from analytics_platform import Platform
87
+ platform = Platform.__new__(Platform)
88
+ wall = self.wall()
89
+ platform._modules = {"firewall": wall}
90
+ status, _, _ = platform.blocked("203.0.113.9", {"sec-fetch-site": "cross-site"}, "GET", "/.env")
91
+ self.assertEqual(status, 403)
92
+ self.assertEqual(wall.bans, {})
93
+
94
+
45
95
  class Cloudflare(unittest.TestCase):
46
96
  """CF-Connecting-IP is the visitor only from Cloudflare: anyone who reaches
47
97
  the origin directly can send it, and would pick their own address past
@@ -546,5 +546,28 @@ class SharedKeysAndHeaders(unittest.TestCase):
546
546
  for name in ("Content-Length", "transfer-encoding", "Connection", "Set-Cookie", "Content-Type"):
547
547
  self.assertFalse(safe_header([name, "1"]), name)
548
548
 
549
+
550
+ class PrivateFields(unittest.TestCase):
551
+ """Form protection is handed the whole form, sign-ups and checkouts
552
+ included: the same private names as platform-php, and card numbers
553
+ taken out of any value."""
554
+
555
+ def test_private_names_are_never_sent(self):
556
+ from analytics_platform.forms import plain_fields
557
+ private = ("password", "pwd", "pin", "otp", "api_key", "apiKey", "cc-number", "cc_num", "cc-csc", "cc-exp",
558
+ "exp_month", "security_code", "iban", "routing", "account_number", "dob", "nonce", "auth",
559
+ "two_factor_mfa", "csrfmiddlewaretoken", "analytics-shield")
560
+ kept = plain_fields({name: "x" for name in private + ("email", "message", "spinach")})
561
+ self.assertEqual(sorted(kept), ["email", "message", "spinach"])
562
+
563
+ def test_a_card_number_in_any_value_is_removed(self):
564
+ from analytics_platform.forms import plain_fields, without_card_numbers
565
+ self.assertEqual(without_card_numbers("pay with 4111 1111 1111 1111 please"), "pay with [removed] please")
566
+ self.assertEqual(without_card_numbers("4111-1111-1111-1111"), "[removed]")
567
+ # Luhn keeps order and phone numbers readable.
568
+ self.assertEqual(without_card_numbers("order 1234567890123"), "order 1234567890123")
569
+ self.assertEqual(plain_fields({"notes": ["5555555555554444", 7]}), {"notes": "[removed]"})
570
+ self.assertEqual(plain_fields({"qty": 3}), {"qty": "3"})
571
+
549
572
  if __name__ == "__main__":
550
573
  unittest.main()
@@ -137,6 +137,19 @@ class ProxyTest(unittest.TestCase):
137
137
  self.assertEqual(status, 404)
138
138
  self.assertEqual(Collector.seen, [])
139
139
 
140
+ def test_a_query_no_url_can_carry_is_refused_and_never_trips_the_breaker(self):
141
+ # http.client would raise on these before connecting; counted as the
142
+ # collector failing, a few a second would stop every visitor's beacons.
143
+ kind, rest = self.proxy.match(self.proxy.collect_url())
144
+ for query in ("x=\xff", "x=\x00", "x=a b", "x=\u00e9"):
145
+ status, _, _ = self.proxy.handle(kind, rest, method="GET", query=query)
146
+ self.assertEqual(status, 400, repr(query))
147
+ self.assertEqual(Collector.seen, [])
148
+ self.assertEqual(self.proxy._failures, 0)
149
+ self.assertFalse(self.proxy.resting())
150
+ status, _, _ = self.proxy.handle(kind, rest, method="GET", query="v=1&u=%2Fa")
151
+ self.assertEqual(status, 202)
152
+
140
153
  def test_switched_off_it_answers_nothing(self):
141
154
  off = Proxy(FakeCore(self.endpoint), enabled=False)
142
155
  self.assertIsNone(off.match(off.script_url()))
@@ -51,6 +51,55 @@ class SharedCases(unittest.TestCase):
51
51
  self.assertEqual(len(compile_rules({"rules": many})), 200)
52
52
 
53
53
 
54
+ class OriginForm(unittest.TestCase):
55
+ """The request line's target as the router reads it: gunicorn routes
56
+ "x:/admin" as "/admin", so the firewall must match it as "/admin" too."""
57
+
58
+ def test_every_shared_target(self):
59
+ from analytics_platform.core import origin_form
60
+ with open(CASES) as handle:
61
+ targets = json.load(handle)["targets"]
62
+ self.assertTrue(targets)
63
+ for case in targets:
64
+ self.assertEqual(origin_form(case["target"]), case["origin"], case["target"])
65
+
66
+ def test_the_firewall_matches_the_routed_target(self):
67
+ from analytics_platform.core import origin_form
68
+ rules = compile_rules({"rules": [{"id": "admin", "action": "block", "path": {"prefix": ["/admin"]}}]})
69
+ self.assertEqual(match_request(rules, "POST", origin_form("x:/admin/login/"), "")["id"], "admin")
70
+
71
+
72
+ class RegexWindows(unittest.TestCase):
73
+ """Only a regex is held to 2,000 characters at a time; a window's edges
74
+ are not the part's, so anchors hold only where the part really starts
75
+ or ends."""
76
+
77
+ def rules(self, pattern):
78
+ return compile_rules({"rules": [{"id": "r", "action": "block", "query": {"regex": pattern}}]})
79
+
80
+ def test_found_past_the_first_window(self):
81
+ target = "/x?" + "a" * 5000 + "union select"
82
+ self.assertEqual(match_request(self.rules("union\\s+select"), "GET", target, "")["id"], "r")
83
+
84
+ def test_a_caret_only_at_the_start(self):
85
+ rules = self.rules("^pad")
86
+ self.assertEqual(match_request(rules, "GET", "/x?pad" + "a" * 5000, "")["id"], "r")
87
+ # 1,000 characters in is a window's start, never the query's.
88
+ self.assertIsNone(match_request(rules, "GET", "/x?" + "a" * 1000 + "pad" + "a" * 5000, ""))
89
+
90
+ def test_a_dollar_only_at_the_end(self):
91
+ rules = self.rules("end$")
92
+ self.assertEqual(match_request(rules, "GET", "/x?" + "a" * 5000 + "end", "")["id"], "r")
93
+ self.assertIsNone(match_request(rules, "GET", "/x?" + "a" * 1997 + "end" + "a" * 3000, ""))
94
+ # An escaped "$" is a character, not an anchor.
95
+ self.assertEqual(match_request(self.rules("end\\$"), "GET", "/x?" + "a" * 1997 + "end$" + "a" * 3000, "")["id"], "r")
96
+
97
+ def test_both_anchors_only_on_a_short_part(self):
98
+ rules = self.rules("^a+$")
99
+ self.assertEqual(match_request(rules, "GET", "/x?" + "a" * 2000, "")["id"], "r")
100
+ self.assertIsNone(match_request(rules, "GET", "/x?" + "a" * 2001, ""))
101
+
102
+
54
103
  class PatternSafety(unittest.TestCase):
55
104
  """(a+)+ takes exponential time on input that nearly matches, and `re`
56
105
  can't be timed out: the client must refuse the pattern, not match it."""