feat: harden bridge origin/CSP, cache decryption, and overhaul the web client UI
This commit is contained in:
parent
f637d5be57
commit
80133da100
10 changed files with 975 additions and 352 deletions
50
bridge.py
50
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue