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.
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/PKG-INFO +1 -1
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/__init__.py +2 -1
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/core.py +19 -1
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/firewall.py +23 -12
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/forms.py +33 -2
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/proxy.py +21 -2
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/queries.py +20 -3
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/request_rules.py +53 -15
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/PKG-INFO +1 -1
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/pyproject.toml +1 -1
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_firewall.py +50 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_hardening.py +23 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_proxy.py +13 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_request_rules.py +49 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/LICENSE +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/README.md +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/_env.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/calls.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/celery.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/challenge.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/django.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/dramatiq.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/queues.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/resources.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/rq.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform/start.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/SOURCES.txt +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/setup.cfg +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_calls_queues.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_celery.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_challenge.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_checkin.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_headers.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_limits.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_page_limits.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_proxy_answer.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_queries.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_redis.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_resources.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_rq_dramatiq.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_start.py +0 -0
- {analytics_platform-0.3.9 → analytics_platform-0.3.11}/tests/test_static_late.py +0 -0
|
@@ -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.
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
17
|
-
# the text comes from whoever is asking, and `re` can't be timed out: a
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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 =
|
|
291
|
-
query =
|
|
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)
|
|
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)
|
|
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)
|
|
303
|
-
"ua": str(user_agent or "")
|
|
340
|
+
"decoded_query": _decode_once(query).lower(),
|
|
341
|
+
"ua": str(user_agent or "").lower(),
|
|
304
342
|
}
|
|
305
343
|
|
|
306
344
|
|
|
@@ -10,7 +10,7 @@ Security = "https://sitegoose.com/security"
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "analytics-platform"
|
|
13
|
-
version = "0.3.
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{analytics_platform-0.3.9 → analytics_platform-0.3.11}/analytics_platform.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|