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:
@@ -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
|
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.
|
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`
|
`share.config` is gitignored because it holds the secret — `share.config.example`
|
||||||
is the tracked template.
|
is the tracked template.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
accept it once. Enter the token on the login page; it is stored in an `HttpOnly`
|
||||||
cookie for a year.
|
cookie for a year.
|
||||||
|
|
||||||
|
You can also hand out a pre-authenticated link, `https://<host>: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
|
## Networking notes
|
||||||
|
|
||||||
- The server binds `0.0.0.0`, so it is reachable from the whole LAN.
|
- The server binds `0.0.0.0`, so it is reachable from the whole LAN.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import hmac
|
|||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -15,6 +16,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from config import CHUNK_SIZE, SNIFF_SIZE, SSL_HANDSHAKE_TIMEOUT
|
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:
|
def file_type(path: Path) -> str:
|
||||||
ct = mimetypes.guess_type(path.name)[0] or ''
|
ct = mimetypes.guess_type(path.name)[0] or ''
|
||||||
@@ -74,8 +78,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
|
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
|
||||||
pass
|
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):
|
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):
|
def _json(self, data):
|
||||||
self._respond(json.dumps(data).encode(), "application/json")
|
self._respond(json.dumps(data).encode(), "application/json")
|
||||||
@@ -98,13 +108,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def _match(self, value: str) -> bool:
|
def _match(self, value: str) -> bool:
|
||||||
return hmac.compare_digest(self._hash(value), self.server.token_hash)
|
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:
|
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:
|
if not self.server.token_hash:
|
||||||
return True
|
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", "")
|
auth = self.headers.get("Authorization", "")
|
||||||
if auth.startswith("Bearer ") and self._match(auth[7:]):
|
if auth.startswith("Bearer ") and self._match(auth[7:]):
|
||||||
return True
|
return True
|
||||||
@@ -148,6 +164,16 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
|
|||||||
</script>
|
</script>
|
||||||
</body></html>'''
|
</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):
|
def _send_auth_page(self):
|
||||||
self.send_response(401)
|
self.send_response(401)
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
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 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 method not in ("_route_auth",) and not self._check_auth():
|
||||||
if self.command == "GET" and path_only in ("/", "/index.html"):
|
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()
|
return self._send_auth_page()
|
||||||
self.send_response(401)
|
self.send_response(401)
|
||||||
body = json.dumps({"error": "unauthorized"}).encode()
|
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_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(resp)))
|
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.send_header("Connection", "close")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(resp)
|
self.wfile.write(resp)
|
||||||
@@ -269,7 +298,7 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
|
|||||||
|
|
||||||
def _route_logout(self, _prefix):
|
def _route_logout(self, _prefix):
|
||||||
self.send_response(302)
|
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("Location", "/")
|
||||||
self.send_header("Connection", "close")
|
self.send_header("Connection", "close")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
Reference in New Issue
Block a user