Skip to content

Commit 3356fd2

Browse files
Gheat1hamidi-dev
authored andcommitted
fix(web): require local host, POST-only reload, security headers
Harden --serve/--web against hostile webpages: /api/reload mutated state on a plain GET, so any page could fire it cross-origin (CSRF / cheap DoS -- every hit re-parses the sources); it is POST-only now, GET answers 405 and the page's refresh button posts. On a loopback bind the Host header must name localhost/127.0.0.1/[::1] (port optional) or the request is refused with 403, closing DNS rebinding; an explicit --bind beyond loopback keeps working and already warns. Every response carries a deny-everything CSP fitting the self-contained page (inline JS/CSS, data: favicon, same-origin fetches) plus X-Content-Type-Options.
1 parent d1cb604 commit 3356fd2

3 files changed

Lines changed: 84 additions & 3 deletions

File tree

src/opentab/web.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,26 +279,62 @@ def _send(self, status: int, ctype: str, body: bytes) -> None:
279279
self.send_response(status)
280280
self.send_header("Content-Type", ctype)
281281
self.send_header("Content-Length", str(len(body)))
282+
# The page is fully self-contained -- inline JS/CSS, a data: favicon, and
283+
# fetches only back to this server -- so everything else can be denied.
284+
self.send_header(
285+
"Content-Security-Policy",
286+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
287+
"img-src data:; connect-src 'self'",
288+
)
289+
self.send_header("X-Content-Type-Options", "nosniff")
282290
self.end_headers()
283291
self.wfile.write(body)
284292

285293
def _send_json(self, data: dict) -> None:
286294
self._send(200, "application/json; charset=utf-8", json.dumps(data).encode("utf-8"))
287295

296+
def _check_host(self) -> bool:
297+
# DNS-rebinding defense: an attacker's domain can be pointed at 127.0.0.1
298+
# and read this server cross-origin, but the Host header still names that
299+
# domain -- so on a loopback bind only local names pass. An explicit
300+
# --bind beyond loopback opted out (and got the startup warning).
301+
if self.server.server_address[0] not in ("127.0.0.1", "::1"):
302+
return True
303+
raw = (self.headers.get("Host") or "").strip().lower()
304+
host = raw[1:].partition("]")[0] if raw.startswith("[") else raw.partition(":")[0]
305+
if host in ("localhost", "127.0.0.1", "::1") or raw == "::1":
306+
return True
307+
self._send(403, "text/plain; charset=utf-8", b"forbidden host")
308+
return False
309+
288310
def do_GET(self):
311+
if not self._check_host():
312+
return
289313
path = self.path.split("?", 1)[0]
290314
server: ReportServer = self.server # type: ignore[assignment]
291315
if path == "/":
292316
self._send(200, "text/html; charset=utf-8", server.page().encode("utf-8"))
293317
elif path == "/api/reload":
294-
server.reload()
295-
self._send_json({"ok": True})
318+
# State-changing, so POST-only: a GET can be fired cross-origin by any
319+
# webpage (CSRF), and each reload re-parses the sources off disk.
320+
self._send(405, "text/plain; charset=utf-8", b"reload is POST-only")
296321
elif path.startswith("/api/session/"):
297322
workflow_id = unquote(path[len("/api/session/") :])
298323
self._send_json(session_extras(server.app, workflow_id))
299324
else:
300325
self._send(404, "text/plain; charset=utf-8", b"not found")
301326

327+
def do_POST(self):
328+
if not self._check_host():
329+
return
330+
path = self.path.split("?", 1)[0]
331+
server: ReportServer = self.server # type: ignore[assignment]
332+
if path == "/api/reload":
333+
server.reload()
334+
self._send_json({"ok": True})
335+
else:
336+
self._send(404, "text/plain; charset=utf-8", b"not found")
337+
302338

303339
class ReportServer(HTTPServer):
304340
"""Serves the rendered report page plus the per-session JSON extras. The page

src/opentab/webpage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,7 @@
10831083
right.appendChild(h('button', { class: 'hbtn', title: 'Model prices (P)', onclick: openPrices }, '$/M prices'));
10841084
right.appendChild(h('button', { class: 'hbtn', title: 'Theme', onclick: openTheme }, '◑ theme'));
10851085
if (META.serve) right.appendChild(h('button', { class: 'hbtn', title: 're-read the data sources',
1086-
onclick: () => fetch('/api/reload').then(() => location.reload()) }, '↻ refresh'));
1086+
onclick: () => fetch('/api/reload', { method: 'POST' }).then(() => location.reload()) }, '↻ refresh'));
10871087
const hints = document.getElementById('hints');
10881088
hints.textContent = '';
10891089
[['j/k', 'move'], ['Tab', 'panel'], ['h/l', 'tabs'], ['Esc', 'back'], ['$', 'what-if'], ['p/t', 'projects/time'], ['T', 'trends'], ['P', 'prices'], ['R', 'range']]

test_opentab.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8177,6 +8177,51 @@ def test_web_report_server_serves_page_extras_and_404():
81778177
thread.join(timeout=5)
81788178

81798179

8180+
def test_web_server_is_hardened_against_csrf_and_dns_rebinding():
8181+
import threading
8182+
import urllib.error
8183+
import urllib.request
8184+
8185+
app = app_with([workflow("w1", "2026-05-01 10:00:00")])
8186+
server = ot.web.ReportServer(("127.0.0.1", 0), app)
8187+
thread = threading.Thread(target=server.serve_forever, daemon=True)
8188+
thread.start()
8189+
port = server.server_address[1]
8190+
base = f"http://127.0.0.1:{port}"
8191+
try:
8192+
# Every response carries the lockdown headers (self-contained page: inline
8193+
# JS/CSS, data: favicon, fetch back to this server only).
8194+
resp = urllib.request.urlopen(base + "/")
8195+
csp = resp.headers["Content-Security-Policy"]
8196+
assert csp.startswith("default-src 'none'")
8197+
assert "connect-src 'self'" in csp and "img-src data:" in csp
8198+
assert resp.headers["X-Content-Type-Options"] == "nosniff"
8199+
# Reload mutates state, so it is POST-only: a GET (fireable cross-origin
8200+
# by any webpage) gets a 405 and does not touch the stores.
8201+
try:
8202+
urllib.request.urlopen(base + "/api/reload")
8203+
raise AssertionError("expected a 405")
8204+
except urllib.error.HTTPError as exc:
8205+
assert exc.code == 405
8206+
req = urllib.request.Request(base + "/api/reload", data=b"", method="POST")
8207+
assert json.loads(urllib.request.urlopen(req).read().decode("utf-8")) == {"ok": True}
8208+
# DNS rebinding: a foreign Host header is rejected on a loopback bind...
8209+
req = urllib.request.Request(base + "/", headers={"Host": "evil.example.com"})
8210+
try:
8211+
urllib.request.urlopen(req)
8212+
raise AssertionError("expected a 403")
8213+
except urllib.error.HTTPError as exc:
8214+
assert exc.code == 403
8215+
# ...while every local spelling passes, with or without the port.
8216+
for host in ("localhost", f"localhost:{port}", "127.0.0.1", f"[::1]:{port}"):
8217+
req = urllib.request.Request(base + "/", headers={"Host": host})
8218+
assert urllib.request.urlopen(req).status == 200
8219+
finally:
8220+
server.shutdown()
8221+
server.server_close()
8222+
thread.join(timeout=5)
8223+
8224+
81808225
def test_cli_web_flag_is_recognized_and_is_distinct_from_serve():
81818226
# --web is its own flag; web_command/main route it through the serve path.
81828227
import sys as _sys

0 commit comments

Comments
 (0)