diff --git a/README.md b/README.md index 048ac57..1485e2b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ The protocol is raw TCP with a Noise_NX handshake, and browsers cannot open TCP browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild (TCP :1961) ``` -`bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file. +`bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. It also relays only for upgrades whose `Origin` is its own page — WebSockets are exempt from CORS, so without that check any page the user happened to visit could drive the relay. `--allow-origin` admits others, and an upgrade carrying no `Origin` at all, which a browser never sends, is left alone for non-browser clients. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file. + +The bridge exists only because the protocol is TCP, and it is the smaller dependency it looks like: `devbox run test` already needs Python and `uv` for the reference vectors and the end-to-end run. What would retire it altogether is a WebSocket transport binding in `smolmaild` itself — Noise messages already carry a `u16` length prefix, so a binary WebSocket stream is a drop-in byte stream — but that is an addition to SPEC.md §4 and to the server, not something this client can decide. ## Run @@ -58,6 +60,6 @@ What is not verified here: clicking through the actual browser UI. `devbox run s ## Notes and limits -- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. No server push or notifications in v1 (SPEC.md §13); click fetch. +- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications in v1 (SPEC.md §13); click fetch. - Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence. - The bridge binds to 127.0.0.1. Serving it beyond localhost is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). diff --git a/bridge.py b/bridge.py index 04cd1e4..465d52d 100755 --- a/bridge.py +++ b/bridge.py @@ -27,8 +27,25 @@ from aiohttp import web log = logging.getLogger("gsmol") +def origin_allowed(request: web.Request) -> bool: + """Whether this upgrade may open a relay. + + WebSockets are exempt from CORS, so without this check any page the user + happens to visit could drive the relay to an allowlisted port on any host. + Browsers always send Origin on an upgrade; non-browser clients (test/client.mjs + on Node) send none, so an absent header is allowed and a foreign one is not. + """ + origin = request.headers.get("Origin") + if origin is None: + return True + own = {f"http://{request.host}", f"https://{request.host}"} + return origin in own or origin in request.app["allow_origins"] + + async def relay(request: web.Request) -> web.WebSocketResponse: """A WebSocket whose binary frames are piped to host:port untouched.""" + if not origin_allowed(request): + raise web.HTTPForbidden(text="cross-origin WebSocket upgrade refused") port = int(request.match_info["port"]) if port not in request.app["relay_ports"]: raise web.HTTPForbidden(text=f"port {port} is not allowed") @@ -63,11 +80,36 @@ async def relay(request: web.Request) -> web.WebSocketResponse: finally: await ws.close() - await asyncio.gather(pump_up(), pump_down()) + # return_exceptions so a failure in one pump cannot leave the other running: + # closing the writer ends the downstream read, closing the socket ends the + # upstream iteration, so each side unblocks the other. + await asyncio.gather(pump_up(), pump_down(), return_exceptions=True) + try: + await writer.wait_closed() + except OSError: + pass log.info("relay to %s:%d closed", host, port) return ws +# The seed lives in localStorage, so an XSS in this page is game over (README). +# The client loads no inline script, no inline style and no foreign origin, so a +# strict policy costs nothing. form-action stays 'self' rather than 'none' so the +# compose dialog's method="dialog" form is untouched. +@web.middleware +async def csp(request: web.Request, handler): + response = await handler(request) + response.headers.setdefault("Content-Security-Policy", "; ".join([ + "default-src 'self'", + f"connect-src 'self' ws://{request.host} wss://{request.host}", + "base-uri 'none'", + "form-action 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + ])) + return response + + async def index(request: web.Request) -> web.FileResponse: return web.FileResponse(Path(request.app["web_dir"]) / "index.html") @@ -80,11 +122,15 @@ def main() -> int: parser.add_argument("--allow-port", type=int, action="append", default=[1961], metavar="PORT", dest="allow_ports", help="relayed TCP port; repeatable") + parser.add_argument("--allow-origin", action="append", default=[], + metavar="ORIGIN", dest="allow_origins", + help="extra browser origin permitted to open a relay; repeatable") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - app = web.Application() + app = web.Application(middlewares=[csp]) app["web_dir"], app["relay_ports"] = args.dir, set(args.allow_ports) + app["allow_origins"] = set(args.allow_origins) app.router.add_get("/", index) app.router.add_get("/tcp/{host}/{port}", relay) app.router.add_static("/", args.dir, show_index=False) diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs index baacbc7..c969c42 100644 --- a/test/vectors.test.mjs +++ b/test/vectors.test.mjs @@ -184,4 +184,46 @@ for (const v of vectors.ed_to_x25519) { eq("cert-len", String(chain[0].length), "136"); } +// --- response framing guards (§6.1) ------------------------------------------- + +// A Session over a scripted server. The cipher states are the identity, so the +// bytes handed in are exactly what call() parses; readNoise wants at least 16, +// and trailing bytes past the frame length are ignored by design. +function scriptedSession(frame) { + const message = crypto.concat(frame, new Uint8Array(Math.max(0, 16 - frame.length))); + const wire = { + queue: crypto.concat(proto.u16BE(message.length), message), + pos: 0, + async readExact(n) { + if (this.pos + n > this.queue.length) throw new Error("script exhausted"); + this.pos += n; + return this.queue.slice(this.pos - n, this.pos); + }, + }; + const passthrough = { encrypt: bytes => bytes, decrypt: bytes => bytes }; + return new proto.Session(wire, { send() {}, close() {} }, passthrough, passthrough); +} + +async function refuses(name, frame, op) { + let threw = false; + try { await scriptedSession(frame).call(op); } catch { threw = true; } + ok(name, threw, "call resolved instead of throwing"); +} + +// The shortest legal response is a type byte and a status byte. +await refuses("frame-too-short", + crypto.concat(proto.u32BE(1), Uint8Array.of(proto.OP.FETCH)), proto.OP.FETCH); + +// A response reuses the request's type byte; a mismatch means the session +// desynchronised, which must not be read as a status. +await refuses("frame-op-mismatch", + crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.RESOLVE, 0)), proto.OP.FETCH); + +// The same frame with the right echo still passes, so the guard is not +// simply rejecting everything. +{ + const session = scriptedSession(crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.FETCH, 0))); + eq("frame-op-echo-ok", (await session.call(proto.OP.FETCH)).status, 0); +} + console.log(`${passed} checks passed`); diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..fdde838 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,6 @@ + diff --git a/web/index.html b/web/index.html index 5f53c05..c0f7425 100644 --- a/web/index.html +++ b/web/index.html @@ -4,17 +4,33 @@