fix(auth): stop leaking the token via URL, log and Referer

A token passed as `?token=` was accepted on every route, so it ended
up in the request log (and journald), browser history and Referer.

- drop `?token=` from _check_auth; API and download routes now take
  only the cookie or an Authorization: Bearer header
- keep pre-authenticated links working: on the index route a valid
  `?token=` is swapped for the cookie and redirected to a clean URL,
  so the secret does not linger in the address bar
- redact `token=` from log output
- send Referrer-Policy: no-referrer, and mark the cookie Secure
This commit is contained in:
2026-07-27 02:08:58 +04:00
parent cac59e6e67
commit c467a4d254
3 changed files with 46 additions and 7 deletions
+36 -7
View File
@@ -7,6 +7,7 @@ import hmac
import http.server
import json
import mimetypes
import re
import ssl
import stat
import urllib.parse
@@ -15,6 +16,9 @@ from pathlib import Path
from config import CHUNK_SIZE, SNIFF_SIZE, SSL_HANDSHAKE_TIMEOUT
# Keeps `?token=...` out of the log / journald.
_TOKEN_RE = re.compile(r"([?&]token=)[^&\s\"]*")
def file_type(path: Path) -> str:
ct = mimetypes.guess_type(path.name)[0] or ''
@@ -74,8 +78,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
pass
def end_headers(self):
# Stops a `?token=` link from leaking the token via Referer on outbound clicks.
self.send_header("Referrer-Policy", "no-referrer")
super().end_headers()
def log_message(self, fmt, *args):
print(f" [{datetime.now():%H:%M:%S}] {args[0]}")
msg = str(args[0]) if args else fmt
print(f" [{datetime.now():%H:%M:%S}] {_TOKEN_RE.sub(r'\1<redacted>', msg)}")
def _json(self, data):
self._respond(json.dumps(data).encode(), "application/json")
@@ -98,13 +108,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
def _match(self, value: str) -> bool:
return hmac.compare_digest(self._hash(value), self.server.token_hash)
def _query_token(self) -> str:
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
return qs.get("token", [""])[0]
def _cookie(self, token: str) -> str:
return f"share_token={self._hash(token)}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=31536000"
def _check_auth(self) -> bool:
# NB: `?token=` is deliberately not accepted here — a token in the URL
# leaks into logs, browser history and Referer. It is exchanged for a
# cookie on the index route only (see _dispatch).
if not self.server.token_hash:
return True
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
if qs.get("token", [None])[0] and self._match(qs["token"][0]):
return True
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer ") and self._match(auth[7:]):
return True
@@ -148,6 +164,16 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
</script>
</body></html>'''
def _token_to_cookie(self, token: str):
"""Swap a valid `?token=` link for a cookie, then redirect to a clean URL."""
self.send_response(302)
self.send_header("Set-Cookie", self._cookie(token))
self.send_header("Location", "/")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.send_header("Connection", "close")
self.end_headers()
def _send_auth_page(self):
self.send_response(401)
self.send_header("Content-Type", "text/html; charset=utf-8")
@@ -178,6 +204,9 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
if path_only == prefix or (len(prefix) > 1 and prefix.endswith("/") and path_only.startswith(prefix)):
if method not in ("_route_auth",) and not self._check_auth():
if self.command == "GET" and path_only in ("/", "/index.html"):
tok = self._query_token()
if tok and self._match(tok):
return self._token_to_cookie(tok)
return self._send_auth_page()
self.send_response(401)
body = json.dumps({"error": "unauthorized"}).encode()
@@ -254,7 +283,7 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.send_header("Set-Cookie", f"share_token={self._hash(data['token'])}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000")
self.send_header("Set-Cookie", self._cookie(data["token"]))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(resp)
@@ -269,7 +298,7 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
def _route_logout(self, _prefix):
self.send_response(302)
self.send_header("Set-Cookie", "share_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
self.send_header("Set-Cookie", "share_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0")
self.send_header("Location", "/")
self.send_header("Connection", "close")
self.end_headers()