From c467a4d25461e12357b1986b493d10854d5fd1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AD=D0=BB=D1=8C=D0=BD=D0=B0=D1=80?= Date: Mon, 27 Jul 2026 02:08:58 +0400 Subject: [PATCH] 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 --- README.md | 4 ++++ SETUP.md | 6 ++++++ server.py | 43 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b3dfcfa..5196ec9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7 Set `token` in `share.config` or pass `--token`. All endpoints require a valid token. An empty `token` means open access to everyone on the network. +The token is accepted as an `Authorization: Bearer` header or the `share_token` +cookie set by the login page. A `?token=` link works only on `/` — it is exchanged +for the cookie and redirected away so the secret does not linger in the URL. + `share.config` is gitignored because it holds the secret — `share.config.example` is the tracked template. diff --git a/SETUP.md b/SETUP.md index d1c5533..5515f52 100644 --- a/SETUP.md +++ b/SETUP.md @@ -91,6 +91,12 @@ browser will show a certificate warning; that is expected for a self-signed cert accept it once. Enter the token on the login page; it is stored in an `HttpOnly` cookie for a year. +You can also hand out a pre-authenticated link, `https://:3001/?token=YOUR_TOKEN`. +Opening it swaps the token for the cookie and immediately redirects to `/`, so the +token does not stay in the address bar, history or Referer. `?token=` works **only** +on that entry page — API and download routes require the cookie or an +`Authorization: Bearer` header, and the token is redacted from the server log. + ## Networking notes - The server binds `0.0.0.0`, so it is reachable from the whole LAN. diff --git a/server.py b/server.py index 6ca4de9..8d9e93b 100644 --- a/server.py +++ b/server.py @@ -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', 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 ''' + 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()