analytics-platform 0.2.1__py3-none-any.whl
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/__init__.py +483 -0
- analytics_platform/analytics.py +101 -0
- analytics_platform/audit.py +53 -0
- analytics_platform/core.py +199 -0
- analytics_platform/crawlers.py +191 -0
- analytics_platform/django.py +112 -0
- analytics_platform/errors.py +113 -0
- analytics_platform/firewall.py +315 -0
- analytics_platform/flags.py +46 -0
- analytics_platform/forms.py +58 -0
- analytics_platform/inventory.py +72 -0
- analytics_platform/maintenance.py +61 -0
- analytics_platform/performance.py +98 -0
- analytics_platform/posture.py +264 -0
- analytics_platform/proxy.py +170 -0
- analytics_platform/request_rules.py +175 -0
- analytics_platform/resources.py +471 -0
- analytics_platform-0.2.1.dist-info/METADATA +227 -0
- analytics_platform-0.2.1.dist-info/RECORD +22 -0
- analytics_platform-0.2.1.dist-info/WHEEL +5 -0
- analytics_platform-0.2.1.dist-info/licenses/LICENSE +21 -0
- analytics_platform-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""How the server is set up (protocol in sdk/protocols/posture.md): the
|
|
2
|
+
settings that leave the app open (Django's, when it's a Django site), whether
|
|
3
|
+
it runs as root, code anyone on the machine can write, and files in the
|
|
4
|
+
folders it serves to the web that should never be public. Sent to
|
|
5
|
+
POST /events/posture a few minutes after start and then once a day (the
|
|
6
|
+
collector's `every`), so Security -> Security check can say what to change.
|
|
7
|
+
|
|
8
|
+
It only reads: nothing here changes a setting, a permission or a file, and no
|
|
9
|
+
file's contents are sent -- paths, sizes, times and short values only. The
|
|
10
|
+
walk is bounded and runs in a background thread, never in a request; with no
|
|
11
|
+
folder served to the web there is nothing to walk and only the settings are
|
|
12
|
+
reported.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import stat
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from .core import Periodic
|
|
21
|
+
|
|
22
|
+
FIRST = 180
|
|
23
|
+
RETRY_EVERY = 60 * 60
|
|
24
|
+
EVERY = 24 * 60 * 60
|
|
25
|
+
MAX_CHECKS = 200
|
|
26
|
+
MAX_EXPOSURE = 50
|
|
27
|
+
MAX_ENTRIES = 20000
|
|
28
|
+
MAX_DEPTH = 6
|
|
29
|
+
MAX_PERM_CHECKS = 3000
|
|
30
|
+
SKIP_DIRS = {".git", ".svn", ".hg", "node_modules", "vendor", "cache", ".cache", ".history", "__pycache__"}
|
|
31
|
+
CODE_FILE = re.compile(r"\.(py|pyw|js|cjs|mjs|php[0-9]?|phtml|rb|pl|sh)$", re.I)
|
|
32
|
+
# Files that shouldn't be downloadable, by name; first match wins.
|
|
33
|
+
EXPOSURE = [
|
|
34
|
+
("database_dump", re.compile(r"\.sql(\.(gz|bz2|xz|zip))?$", re.I)),
|
|
35
|
+
("private_key", re.compile(r"^(id_rsa|id_dsa|id_ecdsa|id_ed25519)$|\.(pem|key|p12|pfx|jks)$", re.I)),
|
|
36
|
+
("env", re.compile(r"^\.env($|\.)|^env\.(ini|txt)$", re.I)),
|
|
37
|
+
("archive", re.compile(r"\.(zip|tar|tgz|tar\.gz|tar\.bz2|rar|7z)$", re.I)),
|
|
38
|
+
("backup", re.compile(r"\.(bak|old|orig|save|swp|copy|backup)$|~$|\.(py|json|conf|config|ini|ya?ml)\.(bak|old|orig|save|txt)$", re.I)),
|
|
39
|
+
("info", re.compile(r"^(phpinfo|info|adminer)\.php$", re.I)),
|
|
40
|
+
("log", re.compile(r"^(debug|error|access)[-_.]?log$|\.log$", re.I)),
|
|
41
|
+
]
|
|
42
|
+
# Django's own placeholders, and what a key looks like when nobody set one.
|
|
43
|
+
INSECURE_KEYS = ("django-insecure-", "changeme", "secret", "your-secret-key", "put-your-secret-key-here")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def kind_of(name):
|
|
47
|
+
for kind, pattern in EXPOSURE:
|
|
48
|
+
if pattern.search(name):
|
|
49
|
+
return kind
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def without_nested(roots):
|
|
54
|
+
"""A folder inside another folder on the list is already covered: without
|
|
55
|
+
this, every file under it would be reported twice."""
|
|
56
|
+
unique = {}
|
|
57
|
+
for root in roots:
|
|
58
|
+
unique.setdefault(root["dir"], root)
|
|
59
|
+
return [root for root in unique.values()
|
|
60
|
+
if not any(other != root["dir"] and (root["dir"] + "/").startswith(other + "/") for other in unique)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def roots_of(given):
|
|
64
|
+
"""[{"dir", "prefix"}]: `prefix` is where the web serves the folder, so the
|
|
65
|
+
path we report is the one the dashboard can ask the site for. Not told:
|
|
66
|
+
Django's STATIC_ROOT and MEDIA_ROOT, when they're set and there."""
|
|
67
|
+
roots = []
|
|
68
|
+
for entry in given or []:
|
|
69
|
+
folder = entry.get("dir") if isinstance(entry, dict) else entry
|
|
70
|
+
if not folder:
|
|
71
|
+
continue
|
|
72
|
+
prefix = str(entry.get("prefix") or "").strip("/") if isinstance(entry, dict) else ""
|
|
73
|
+
roots.append({"dir": os.path.abspath(str(folder)), "prefix": prefix})
|
|
74
|
+
if roots:
|
|
75
|
+
return without_nested(roots)
|
|
76
|
+
for folder, url in django_static_dirs():
|
|
77
|
+
if folder and os.path.isdir(folder):
|
|
78
|
+
roots.append({"dir": os.path.abspath(folder), "prefix": str(url or "").strip("/")})
|
|
79
|
+
return without_nested(roots)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def django_settings():
|
|
83
|
+
"""Django's settings, or None when this isn't a configured Django site."""
|
|
84
|
+
try:
|
|
85
|
+
from django.conf import settings
|
|
86
|
+
if not settings.configured:
|
|
87
|
+
return None
|
|
88
|
+
settings.DEBUG # raises when there are no settings to read
|
|
89
|
+
return settings
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def django_static_dirs():
|
|
95
|
+
settings = django_settings()
|
|
96
|
+
if settings is None:
|
|
97
|
+
return []
|
|
98
|
+
out = []
|
|
99
|
+
for folder, url in ((getattr(settings, "STATIC_ROOT", None), getattr(settings, "STATIC_URL", "")),
|
|
100
|
+
(getattr(settings, "MEDIA_ROOT", None), getattr(settings, "MEDIA_URL", ""))):
|
|
101
|
+
if folder:
|
|
102
|
+
out.append((str(folder), str(url or "")))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def django_checks():
|
|
107
|
+
"""The Django settings that decide how much a hole in the site is worth."""
|
|
108
|
+
settings = django_settings()
|
|
109
|
+
if settings is None:
|
|
110
|
+
return []
|
|
111
|
+
checks = []
|
|
112
|
+
|
|
113
|
+
def add(check_id, failed, value=""):
|
|
114
|
+
row = {"id": check_id, "state": "unknown" if failed is None else ("fail" if failed else "pass")}
|
|
115
|
+
if value:
|
|
116
|
+
row["value"] = value
|
|
117
|
+
checks.append(row)
|
|
118
|
+
|
|
119
|
+
add("app.django_debug", bool(getattr(settings, "DEBUG", False)))
|
|
120
|
+
key = str(getattr(settings, "SECRET_KEY", "") or "")
|
|
121
|
+
add("app.secret_key", not key or len(key) < 32 or any(mark in key.lower() for mark in INSECURE_KEYS))
|
|
122
|
+
hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
|
|
123
|
+
add("app.allowed_hosts", "*" in hosts, ", ".join(str(h) for h in hosts[:10]))
|
|
124
|
+
# Cookie flags only matter once the site is on HTTPS, which these say it is.
|
|
125
|
+
https = bool(getattr(settings, "SECURE_SSL_REDIRECT", False)) or bool(getattr(settings, "SESSION_COOKIE_SECURE", False)) \
|
|
126
|
+
or bool(getattr(settings, "CSRF_COOKIE_SECURE", False)) or bool(getattr(settings, "SECURE_HSTS_SECONDS", 0))
|
|
127
|
+
add("app.session_cookie_httponly", not getattr(settings, "SESSION_COOKIE_HTTPONLY", True))
|
|
128
|
+
if https:
|
|
129
|
+
add("app.session_cookie_secure", not getattr(settings, "SESSION_COOKIE_SECURE", False))
|
|
130
|
+
add("app.csrf_cookie_secure", not getattr(settings, "CSRF_COOKIE_SECURE", False))
|
|
131
|
+
add("app.https_redirect", not getattr(settings, "SECURE_SSL_REDIRECT", False))
|
|
132
|
+
return checks
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def app_checks():
|
|
136
|
+
checks = []
|
|
137
|
+
for check in django_checks():
|
|
138
|
+
checks.append(check)
|
|
139
|
+
if hasattr(os, "getuid"):
|
|
140
|
+
# Running the web app as root turns any hole in it into the whole machine.
|
|
141
|
+
checks.append({"id": "server.runs_as_root", "state": "fail" if os.getuid() == 0 else "pass"})
|
|
142
|
+
return checks
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def walk(roots):
|
|
146
|
+
"""One bounded walk per root: (exposure, writable or None, examples, truncated)."""
|
|
147
|
+
exposure, examples = [], []
|
|
148
|
+
writable = checked = entries = 0
|
|
149
|
+
for root in roots:
|
|
150
|
+
queue = [(root["dir"], 0)]
|
|
151
|
+
while queue:
|
|
152
|
+
folder, depth = queue.pop(0)
|
|
153
|
+
try:
|
|
154
|
+
names = os.listdir(folder)
|
|
155
|
+
except OSError:
|
|
156
|
+
continue
|
|
157
|
+
for name in names:
|
|
158
|
+
entries += 1
|
|
159
|
+
if entries > MAX_ENTRIES:
|
|
160
|
+
return exposure, (writable if checked else None), examples, True
|
|
161
|
+
path = os.path.join(folder, name)
|
|
162
|
+
if os.path.islink(path):
|
|
163
|
+
continue
|
|
164
|
+
relative = "/".join(p for p in (root["prefix"], os.path.relpath(path, root["dir"]).replace(os.sep, "/")) if p)
|
|
165
|
+
if os.path.isdir(path):
|
|
166
|
+
if name in (".git", ".svn", ".hg", ".bzr"):
|
|
167
|
+
if len(exposure) < MAX_EXPOSURE:
|
|
168
|
+
exposure.append({"path": relative + "/config", "kind": "vcs"})
|
|
169
|
+
continue
|
|
170
|
+
if depth + 1 <= MAX_DEPTH and name not in SKIP_DIRS:
|
|
171
|
+
queue.append((path, depth + 1))
|
|
172
|
+
continue
|
|
173
|
+
if not os.path.isfile(path):
|
|
174
|
+
continue
|
|
175
|
+
kind = kind_of(name)
|
|
176
|
+
if kind and len(exposure) < MAX_EXPOSURE:
|
|
177
|
+
row = {"path": relative, "kind": kind}
|
|
178
|
+
try:
|
|
179
|
+
info = os.stat(path)
|
|
180
|
+
row["size"] = info.st_size
|
|
181
|
+
row["mtime"] = datetime.fromtimestamp(info.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
exposure.append(row)
|
|
185
|
+
if checked < MAX_PERM_CHECKS and CODE_FILE.search(name):
|
|
186
|
+
checked += 1
|
|
187
|
+
try:
|
|
188
|
+
if os.stat(path).st_mode & stat.S_IWOTH:
|
|
189
|
+
writable += 1
|
|
190
|
+
if len(examples) < 3:
|
|
191
|
+
examples.append(relative)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
194
|
+
return exposure, (writable if checked else None), examples, False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Posture:
|
|
198
|
+
"""Options: roots (the folders the web serves, as "public" or
|
|
199
|
+
{"dir": ..., "prefix": "static"}; default: Django's STATIC_ROOT and
|
|
200
|
+
MEDIA_ROOT), host (the server's name), checks (a callable returning more
|
|
201
|
+
checks of your own)."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, core, roots=None, host=None, checks=None, **_):
|
|
204
|
+
self.core = core
|
|
205
|
+
self.roots = roots_of(roots)
|
|
206
|
+
self.extra = checks
|
|
207
|
+
self.host = re.sub(r"[^A-Za-z0-9._-]", "-", str(host if host is not None else socket.gethostname()))[:100] or "server"
|
|
208
|
+
self.job = Periodic(self._report, FIRST) if core.valid else None
|
|
209
|
+
|
|
210
|
+
def report(self):
|
|
211
|
+
"""What would be reported now (also used by the job and by tests)."""
|
|
212
|
+
exposure, writable, examples, truncated = walk(self.roots)
|
|
213
|
+
checks = app_checks()
|
|
214
|
+
if writable is not None:
|
|
215
|
+
row = {"id": "server.code_writable", "state": "fail" if writable else "pass"}
|
|
216
|
+
if writable:
|
|
217
|
+
row["value"] = "%d files, e.g. %s" % (writable, ", ".join(examples))
|
|
218
|
+
checks.append(row)
|
|
219
|
+
try:
|
|
220
|
+
for check in (self.extra() if callable(self.extra) else []) or []:
|
|
221
|
+
if isinstance(check, dict) and check.get("id") and check.get("state") in ("pass", "fail", "unknown"):
|
|
222
|
+
row = {"id": str(check["id"]).lower()[:60], "state": check["state"]}
|
|
223
|
+
if check.get("value"):
|
|
224
|
+
row["value"] = str(check["value"])[:200]
|
|
225
|
+
checks.append(row)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
import platform as _platform
|
|
229
|
+
body = {
|
|
230
|
+
"site_id": self.core.site_id,
|
|
231
|
+
"host": self.host,
|
|
232
|
+
"at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
233
|
+
"checks": checks[:MAX_CHECKS],
|
|
234
|
+
"facts": {"agent": "python/%s" % _platform.python_version()},
|
|
235
|
+
}
|
|
236
|
+
if self.roots:
|
|
237
|
+
# Left out when there's no folder to walk: the dashboard then says
|
|
238
|
+
# this wasn't checked, instead of taking an empty walk for a clean one.
|
|
239
|
+
body["exposure"] = exposure[:MAX_EXPOSURE]
|
|
240
|
+
body["facts"]["webroot"] = self.roots[0]["dir"]
|
|
241
|
+
if truncated:
|
|
242
|
+
body["truncated"] = True
|
|
243
|
+
return body
|
|
244
|
+
|
|
245
|
+
def _report(self):
|
|
246
|
+
# The set-up checks ride the software inventory switch.
|
|
247
|
+
if not self.core.is_on("inventory"):
|
|
248
|
+
return RETRY_EVERY
|
|
249
|
+
res = self.core.send("posture", "/events/posture", auth="bearer", body=self.report())
|
|
250
|
+
if res is None or not getattr(res, "ok", False):
|
|
251
|
+
return RETRY_EVERY
|
|
252
|
+
try:
|
|
253
|
+
answer = res.json()
|
|
254
|
+
except Exception:
|
|
255
|
+
answer = {}
|
|
256
|
+
every = int(answer.get("every") or EVERY) if isinstance(answer, dict) else EVERY
|
|
257
|
+
return max(RETRY_EVERY, every)
|
|
258
|
+
|
|
259
|
+
def flush(self):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
def close(self):
|
|
263
|
+
if self.job:
|
|
264
|
+
self.job.stop()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""First-party proxy: serving the tracker from the site's own domain, so ad
|
|
2
|
+
blockers don't hide visitors.
|
|
3
|
+
|
|
4
|
+
Blockers stop analytics by recognising the address a script comes from and
|
|
5
|
+
what it is called ("track.js", "collect"). The answer is to serve both from
|
|
6
|
+
the site's own domain, which until now meant writing Nginx, Cloudflare or
|
|
7
|
+
Vercel rules by hand. This app is already a server on that domain, so the
|
|
8
|
+
package does it itself:
|
|
9
|
+
|
|
10
|
+
<prefix>/js/<rest> -> <endpoint>/<rest> (tracker files)
|
|
11
|
+
<prefix>/api/<rest> -> <endpoint>/collectanalytics/<rest> (the collector)
|
|
12
|
+
|
|
13
|
+
The same two rules as the hand-written ones, so one install check covers both.
|
|
14
|
+
The prefix comes from the site secret: not guessable from the page, and the
|
|
15
|
+
same every time, so a browser's cache and a CDN in front stay valid.
|
|
16
|
+
|
|
17
|
+
It answers before every other module: a visitor's beacon is our own traffic,
|
|
18
|
+
not a page of the site, so it shouldn't be counted, timed or rate-limited.
|
|
19
|
+
"""
|
|
20
|
+
import hashlib
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
# The tracker files, by name: anything else is refused rather than forwarded.
|
|
27
|
+
FILES = frozenset((
|
|
28
|
+
"track.js", "s.js", "snapshot.js", "replay.js", "survey.js", "banner.js",
|
|
29
|
+
"leadform.js", "notice.js", "editor.js", "picker.js", "feedback.js", "shield.js",
|
|
30
|
+
))
|
|
31
|
+
# A pinned version never changes; /v<major>/ follows new releases, so it is
|
|
32
|
+
# held for an hour, the same as the collector serves it with.
|
|
33
|
+
TTL_PINNED_S = 7 * 24 * 60 * 60
|
|
34
|
+
TTL_MAJOR_S = 60 * 60
|
|
35
|
+
TIMEOUT = 5
|
|
36
|
+
MAX_BODY = 6 * 1024 * 1024
|
|
37
|
+
# The collector reads these; everything else, cookies above all, stays here.
|
|
38
|
+
PASS_HEADERS = ("content-type", "user-agent", "accept-language", "referer", "origin",
|
|
39
|
+
"sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform")
|
|
40
|
+
SAFE_PATH = re.compile(r"^[A-Za-z0-9/_.-]{1,120}$")
|
|
41
|
+
FILE_PATH = re.compile(r"^v(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)$")
|
|
42
|
+
NOT_FOUND = (404, [("Content-Type", "text/plain; charset=utf-8")], b"Not found")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prefix_for(secret):
|
|
46
|
+
return hashlib.sha256(("proxy|" + str(secret)).encode("utf-8")).hexdigest()[:8]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Proxy:
|
|
50
|
+
"""prefix: the path it answers on (default: from the secret)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, core, prefix=None, enabled=True):
|
|
53
|
+
self.core = core
|
|
54
|
+
self.enabled = enabled
|
|
55
|
+
self.prefix = re.sub(r"[^A-Za-z0-9_-]", "", str(prefix or prefix_for(core.secret)))
|
|
56
|
+
# One copy of each tracker file, so a repeat visitor costs no upstream
|
|
57
|
+
# request. A handful of small files: it never grows.
|
|
58
|
+
self._files = {}
|
|
59
|
+
|
|
60
|
+
def on(self):
|
|
61
|
+
return bool(self.enabled and self.core.valid and self.prefix and self.core.is_on("proxy"))
|
|
62
|
+
|
|
63
|
+
def script_url(self, version="1"):
|
|
64
|
+
"""The tracker's address on this site, for the page's script tag."""
|
|
65
|
+
return "/%s/js/v%s/s.js" % (self.prefix, version)
|
|
66
|
+
|
|
67
|
+
def collect_url(self):
|
|
68
|
+
"""Where visits are sent, for the tag's data-collect."""
|
|
69
|
+
return "/%s/api/e" % self.prefix
|
|
70
|
+
|
|
71
|
+
def match(self, path):
|
|
72
|
+
"""("js" | "api", the rest) when the address is ours, else None."""
|
|
73
|
+
if not self.on():
|
|
74
|
+
return None
|
|
75
|
+
head = "/%s/" % self.prefix
|
|
76
|
+
if not path.startswith(head):
|
|
77
|
+
return None
|
|
78
|
+
rest = path[len(head):]
|
|
79
|
+
for kind in ("js", "api"):
|
|
80
|
+
if rest.startswith(kind + "/"):
|
|
81
|
+
tail = rest[len(kind) + 1:]
|
|
82
|
+
if tail and ".." not in tail and SAFE_PATH.match(tail):
|
|
83
|
+
return kind, tail
|
|
84
|
+
break
|
|
85
|
+
return "bad", ""
|
|
86
|
+
|
|
87
|
+
def handle(self, kind, rest, method="GET", query="", headers=None, body=None, client_ip=""):
|
|
88
|
+
"""(status, [(name, value)], body). Blocking: an ASGI caller runs it
|
|
89
|
+
in a thread so the event loop keeps turning."""
|
|
90
|
+
if kind == "js":
|
|
91
|
+
return self._file(rest)
|
|
92
|
+
if kind != "api":
|
|
93
|
+
return NOT_FOUND
|
|
94
|
+
return self._collector(rest, method, query, headers or {}, body, client_ip)
|
|
95
|
+
|
|
96
|
+
def _file(self, rest):
|
|
97
|
+
found = FILE_PATH.match(rest)
|
|
98
|
+
if not found or found.group(2) not in FILES:
|
|
99
|
+
return NOT_FOUND
|
|
100
|
+
pinned = "." in found.group(1)
|
|
101
|
+
held = self._files.get(rest)
|
|
102
|
+
body = None
|
|
103
|
+
if held and held[1] > time.time() - (TTL_PINNED_S if pinned else TTL_MAJOR_S):
|
|
104
|
+
body = held[0]
|
|
105
|
+
if body is None:
|
|
106
|
+
try:
|
|
107
|
+
request = urllib.request.Request(self.core.endpoint + "/" + rest, method="GET")
|
|
108
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as answer:
|
|
109
|
+
fetched = answer.read(MAX_BODY)
|
|
110
|
+
if len(fetched) > 500:
|
|
111
|
+
body = fetched
|
|
112
|
+
self._files[rest] = (body, time.time())
|
|
113
|
+
except Exception as error:
|
|
114
|
+
self.core.log("proxy", "could not fetch %s: %s" % (rest, error))
|
|
115
|
+
# Upstream is unreachable: an old copy beats no tracker at all.
|
|
116
|
+
if body is None and held:
|
|
117
|
+
body = held[0]
|
|
118
|
+
if body is None:
|
|
119
|
+
return 502, [], b""
|
|
120
|
+
return 200, [
|
|
121
|
+
("Content-Type", "application/javascript; charset=utf-8"),
|
|
122
|
+
("Cache-Control", "public, max-age=31536000, immutable" if pinned else "public, max-age=3600"),
|
|
123
|
+
("X-Content-Type-Options", "nosniff"),
|
|
124
|
+
], body
|
|
125
|
+
|
|
126
|
+
def _collector(self, rest, method, query, headers, body, client_ip):
|
|
127
|
+
method = (method or "GET").upper()
|
|
128
|
+
if method == "OPTIONS":
|
|
129
|
+
# Same origin as the page, so a preflight shouldn't happen; answer
|
|
130
|
+
# it cheaply rather than passing it on.
|
|
131
|
+
return 204, [("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
|
|
132
|
+
("Access-Control-Allow-Headers", "Content-Type"),
|
|
133
|
+
("Access-Control-Max-Age", "86400")], b""
|
|
134
|
+
if method not in ("GET", "POST"):
|
|
135
|
+
return NOT_FOUND
|
|
136
|
+
if body is not None and len(body) > MAX_BODY:
|
|
137
|
+
return 413, [], b""
|
|
138
|
+
# The visitor's address: the collector needs it for the country, the
|
|
139
|
+
# daily visitor ID and the per-visitor rate limit.
|
|
140
|
+
out = {"X-Forwarded-For": client_ip or ""}
|
|
141
|
+
for name in PASS_HEADERS:
|
|
142
|
+
value = headers.get(name)
|
|
143
|
+
if value:
|
|
144
|
+
out[name.title()] = str(value).replace("\r", "").replace("\n", "")
|
|
145
|
+
url = self.core.endpoint + "/collectanalytics/" + rest + (("?" + query) if query else "")
|
|
146
|
+
request = urllib.request.Request(url, data=body if method == "POST" else None, headers=out, method=method)
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as answer:
|
|
149
|
+
return answer.status, self._answer_headers(answer.headers), answer.read(MAX_BODY)
|
|
150
|
+
except urllib.error.HTTPError as answer:
|
|
151
|
+
return answer.code, self._answer_headers(answer.headers), answer.read(MAX_BODY)
|
|
152
|
+
except Exception as error:
|
|
153
|
+
# The tracker already treats a failed beacon as lost and carries on.
|
|
154
|
+
self.core.log("proxy", "could not reach the collector: %s" % error)
|
|
155
|
+
return 502, [], b""
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _answer_headers(headers):
|
|
159
|
+
out = []
|
|
160
|
+
for name in ("Content-Type", "Cache-Control", "Retry-After"):
|
|
161
|
+
value = headers.get(name)
|
|
162
|
+
if value:
|
|
163
|
+
out.append((name, value))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
def flush(self):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def close(self):
|
|
170
|
+
pass
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Request rules (virtual patching; protocol in sdk/protocols/firewall.md):
|
|
2
|
+
the firewall has always matched addresses, these match the request -- its
|
|
3
|
+
method, path, query string and user agent -- so an attack is turned away
|
|
4
|
+
before the vulnerable code runs. They arrive with the other rules and are
|
|
5
|
+
compiled once per fetch; matching is plain string work on text we already
|
|
6
|
+
have, so it costs nothing per request.
|
|
7
|
+
|
|
8
|
+
The matching is the same in every package: the shared cases are in
|
|
9
|
+
sdk/protocols/request-rules-cases.json, and this module is run against them by
|
|
10
|
+
tests/test_request_rules.py.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
from urllib.parse import unquote_plus
|
|
14
|
+
|
|
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.
|
|
19
|
+
MAX_TEXT = 2000
|
|
20
|
+
MAX_REGEX = 200
|
|
21
|
+
SECTIONS = ("path", "query", "ua")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _lower_list(entries, allow_empty=False):
|
|
25
|
+
return [e.lower() for e in entries if isinstance(e, str) and (allow_empty or e != "")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_pattern(pattern):
|
|
29
|
+
"""A pattern can only come from us, but a careless one would be matched
|
|
30
|
+
against text an attacker chooses, in this very request, and `re` can't be
|
|
31
|
+
timed out. So the client checks the shape itself instead of trusting what it
|
|
32
|
+
was handed: a quantifier on a group that itself repeats ("(a+)+") takes
|
|
33
|
+
exponential time on input that nearly matches. Backreferences and lookbehind
|
|
34
|
+
are refused for the same reason."""
|
|
35
|
+
if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_REGEX:
|
|
36
|
+
return False
|
|
37
|
+
if re.search(r"\\[1-9]", pattern) or "(?<" in pattern:
|
|
38
|
+
return False
|
|
39
|
+
open_at = []
|
|
40
|
+
i = 0
|
|
41
|
+
while i < len(pattern):
|
|
42
|
+
char = pattern[i]
|
|
43
|
+
if char == "\\":
|
|
44
|
+
i += 2
|
|
45
|
+
continue
|
|
46
|
+
if char == "[":
|
|
47
|
+
# A character class: quantifiers inside it are literals.
|
|
48
|
+
i += 1
|
|
49
|
+
while i < len(pattern) and pattern[i] != "]":
|
|
50
|
+
i += 2 if pattern[i] == "\\" else 1
|
|
51
|
+
i += 1
|
|
52
|
+
continue
|
|
53
|
+
if char == "(":
|
|
54
|
+
open_at.append(i)
|
|
55
|
+
elif char == ")":
|
|
56
|
+
if not open_at:
|
|
57
|
+
return False
|
|
58
|
+
start = open_at.pop()
|
|
59
|
+
nxt = pattern[i + 1] if i + 1 < len(pattern) else ""
|
|
60
|
+
if nxt in ("*", "+", "{"):
|
|
61
|
+
inside = pattern[start + 1:i]
|
|
62
|
+
if re.search(r"(^|[^\\])[*+]", inside) or re.search(r"(^|[^\\])\{[0-9]", inside):
|
|
63
|
+
return False
|
|
64
|
+
i += 1
|
|
65
|
+
return not open_at
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _section(section):
|
|
69
|
+
if not isinstance(section, dict):
|
|
70
|
+
return None
|
|
71
|
+
out = {}
|
|
72
|
+
for matcher in ("exact", "prefix", "suffix", "contains"):
|
|
73
|
+
entries = section.get(matcher)
|
|
74
|
+
if not isinstance(entries, list):
|
|
75
|
+
continue
|
|
76
|
+
values = _lower_list(entries, allow_empty=matcher == "exact")
|
|
77
|
+
if values:
|
|
78
|
+
out[matcher] = values
|
|
79
|
+
pattern = section.get("regex")
|
|
80
|
+
if safe_pattern(pattern):
|
|
81
|
+
try:
|
|
82
|
+
out["regex"] = re.compile(pattern, re.IGNORECASE)
|
|
83
|
+
except re.error:
|
|
84
|
+
# A pattern we can't compile is dropped; the rule's other parts stay.
|
|
85
|
+
pass
|
|
86
|
+
return out or None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compile_rules(data):
|
|
90
|
+
"""The rules as they will be matched; [] when there are none."""
|
|
91
|
+
given = data.get("rules") if isinstance(data, dict) else None
|
|
92
|
+
rules = []
|
|
93
|
+
for rule in (given if isinstance(given, list) else [])[:MAX_RULES]:
|
|
94
|
+
if not isinstance(rule, dict) or not isinstance(rule.get("id"), str) or not rule["id"]:
|
|
95
|
+
continue
|
|
96
|
+
# We are called before the body is read, so a rule that needs the body
|
|
97
|
+
# is skipped whole rather than matched on the rest of it.
|
|
98
|
+
if "body" in rule:
|
|
99
|
+
continue
|
|
100
|
+
methods = rule.get("methods")
|
|
101
|
+
compiled = {
|
|
102
|
+
"id": rule["id"],
|
|
103
|
+
"action": "block" if rule.get("action") == "block" else "monitor",
|
|
104
|
+
"ban": int(rule["ban"]) if isinstance(rule.get("ban"), (int, float)) and int(rule["ban"]) > 0 else 0,
|
|
105
|
+
"methods": [m.upper() for m in methods if isinstance(m, str)] if isinstance(methods, list) else [],
|
|
106
|
+
}
|
|
107
|
+
sections = 0
|
|
108
|
+
for name in SECTIONS:
|
|
109
|
+
section = _section(rule.get(name))
|
|
110
|
+
if section is not None:
|
|
111
|
+
compiled[name] = section
|
|
112
|
+
sections += 1
|
|
113
|
+
# A rule with nothing to match would match everything.
|
|
114
|
+
if not sections:
|
|
115
|
+
continue
|
|
116
|
+
rules.append(compiled)
|
|
117
|
+
return rules
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _decode_once(text):
|
|
121
|
+
"""One pass only, as the protocol says: enough for %2e%2e, and no risk of
|
|
122
|
+
decoding a visitor's own text into something that looks like an attack."""
|
|
123
|
+
if "%" not in text and "+" not in text:
|
|
124
|
+
return text
|
|
125
|
+
try:
|
|
126
|
+
return unquote_plus(text)
|
|
127
|
+
except Exception:
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _normalize_path(path):
|
|
132
|
+
return re.sub(r"/{2,}", "/", path.lower().replace("\\", "/"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _hits(section, raw, decoded):
|
|
136
|
+
texts = (raw,) if decoded == raw else (raw, decoded)
|
|
137
|
+
for text in texts:
|
|
138
|
+
if text in section.get("exact", ()):
|
|
139
|
+
return True
|
|
140
|
+
if any(text.startswith(entry) for entry in section.get("prefix", ())):
|
|
141
|
+
return True
|
|
142
|
+
if any(entry and text.endswith(entry) for entry in section.get("suffix", ())):
|
|
143
|
+
return True
|
|
144
|
+
if any(entry and entry in text for entry in section.get("contains", ())):
|
|
145
|
+
return True
|
|
146
|
+
regex = section.get("regex")
|
|
147
|
+
if regex is not None and regex.search(text):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def match_request(rules, method="", path="", user_agent=""):
|
|
153
|
+
"""The first rule that matches, or None. `path` is the request target as
|
|
154
|
+
the server received it, query string and all."""
|
|
155
|
+
if not rules:
|
|
156
|
+
return None
|
|
157
|
+
target = str(path or "")
|
|
158
|
+
mark = target.find("?")
|
|
159
|
+
raw_path = _normalize_path((target if mark == -1 else target[:mark])[:MAX_TEXT])
|
|
160
|
+
raw_query = ("" if mark == -1 else target[mark + 1:])[:MAX_TEXT].lower()
|
|
161
|
+
ua = str(user_agent or "")[:MAX_TEXT].lower()
|
|
162
|
+
decoded_path = _normalize_path(_decode_once((target if mark == -1 else target[:mark])[:MAX_TEXT])[:MAX_TEXT])
|
|
163
|
+
decoded_query = _decode_once(raw_query)[:MAX_TEXT]
|
|
164
|
+
upper = str(method or "").upper()
|
|
165
|
+
for rule in rules:
|
|
166
|
+
if rule["methods"] and upper not in rule["methods"]:
|
|
167
|
+
continue
|
|
168
|
+
if "path" in rule and not _hits(rule["path"], raw_path, decoded_path):
|
|
169
|
+
continue
|
|
170
|
+
if "query" in rule and not _hits(rule["query"], raw_query, decoded_query):
|
|
171
|
+
continue
|
|
172
|
+
if "ua" in rule and not _hits(rule["ua"], ua, ua):
|
|
173
|
+
continue
|
|
174
|
+
return rule
|
|
175
|
+
return None
|