feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers); header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect. - F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst, before password compare). - F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0). - F5: scrub error logs to e.message/.code (no DSN leak, INV9). relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
This commit is contained in:
@@ -13,6 +13,7 @@ import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||||
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||
import { serveStatic } from './static-web.js'
|
||||
import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js'
|
||||
|
||||
export interface BrowserServerOptions {
|
||||
readonly certPath: string
|
||||
@@ -50,12 +51,26 @@ function parseCookies(header: string | undefined): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
||||
/**
|
||||
* Build the P1 `UpgradeRequest` from the raw upgrade request.
|
||||
*
|
||||
* DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it),
|
||||
* but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra
|
||||
* `term.dpop.<b64u>` subprotocol entry. The header WINS when both are present; otherwise the proof
|
||||
* falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here:
|
||||
* the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from
|
||||
* client-supplied claims — so both transports feed the SAME downstream DPoP binding.
|
||||
*
|
||||
* `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2).
|
||||
*/
|
||||
export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest {
|
||||
const proto = req.headers['sec-websocket-protocol']
|
||||
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
const dpopHeader = req.headers['dpop']
|
||||
const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null
|
||||
const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols)
|
||||
return {
|
||||
host: req.headers.host ?? '',
|
||||
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
@@ -63,8 +78,8 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
||||
subprotocols,
|
||||
cookies: parseCookies(req.headers.cookie),
|
||||
remoteAddr: req.socket.remoteAddress ?? '',
|
||||
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
|
||||
activeSessionCount: 0,
|
||||
dpop: { proof, publicKeyThumbprint: null },
|
||||
activeSessionCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +113,24 @@ export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||
handleProtocols: (protocols: Set<string>) =>
|
||||
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
||||
})
|
||||
// F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent
|
||||
// splice index PRIVATE (no public per-host stream count), so we approximate the account's "active
|
||||
// sessions" with the number of browser WS connections currently open to the SAME tenant origin
|
||||
// (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live
|
||||
// connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This
|
||||
// over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap).
|
||||
const liveByTenant = new Map<string, number>()
|
||||
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
||||
const tenantKey = req.headers.host ?? ''
|
||||
const priorCount = liveByTenant.get(tenantKey) ?? 0
|
||||
liveByTenant.set(tenantKey, priorCount + 1)
|
||||
ws.once('close', () => {
|
||||
const next = (liveByTenant.get(tenantKey) ?? 1) - 1
|
||||
if (next <= 0) liveByTenant.delete(tenantKey)
|
||||
else liveByTenant.set(tenantKey, next)
|
||||
})
|
||||
opts.node
|
||||
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
|
||||
.handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws))
|
||||
.catch((e) => opts.onError?.(e))
|
||||
})
|
||||
server.on('error', (e) => opts.onError?.(e))
|
||||
|
||||
Reference in New Issue
Block a user