feat: harden bridge origin/CSP, cache decryption, and overhaul the web client UI

This commit is contained in:
randogoth 2026-09-26 19:04:36 +03:00
parent f637d5be57
commit 80133da100
10 changed files with 975 additions and 352 deletions

View file

@ -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)