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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user