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:
Yaojia Wang
2026-07-06 16:26:05 +02:00
parent aa1912b962
commit bfe1be1dfe
8 changed files with 378 additions and 17 deletions

View File

@@ -71,6 +71,19 @@ function intEnv(name: string, fallback: number): number {
return n
}
/**
* F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry
* the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when
* present, e.g. 'ECONNREFUSED') and never the object itself.
*/
function errText(e: unknown): string {
if (e instanceof Error) {
const code = (e as { code?: unknown }).code
return code === undefined ? e.message : `${e.message} (code=${String(code)})`
}
return String(e)
}
// ── async mTLS → sync-slot bridge ───────────────────────────────────────────────────────────────
interface MtlsBridge {
@@ -169,9 +182,9 @@ async function main(): Promise<void> {
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
hosts: deps.hosts,
now,
onError: (e) => console.error('[mtls-verify]', e),
onError: (e) => console.error('[mtls-verify]', errText(e)),
})
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', e))
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e)))
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
@@ -183,7 +196,7 @@ async function main(): Promise<void> {
bindHost,
bindPort: agentBindPort,
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
onError: (e) => console.error('[agent-mtls]', e),
onError: (e) => console.error('[agent-mtls]', errText(e)),
})
const dp = buildDataPlane({
@@ -193,7 +206,7 @@ async function main(): Promise<void> {
mtls: mtlsBridge.sync,
now,
caBundle: [readFileSync(agentCaCertPath)],
onError: (e) => console.error('[data-plane]', e),
onError: (e) => console.error('[data-plane]', errText(e)),
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
})
@@ -205,12 +218,16 @@ async function main(): Promise<void> {
let mintEnabled = false
if (operatorPassword.length > 0 && signPrivRaw.length > 0) {
const signingKey = await loadSigningKeyFromEnv(signPrivRaw)
// F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell).
// Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored).
const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt'
onRequest = createAuthMintRoute({
signingKey,
hosts: stores.hosts,
operatorPassword,
now,
onError: (e) => console.error('[auth-mint]', e),
rateLimit: { buckets: deps.buckets, salt: mintRateSalt },
onError: (e) => console.error('[auth-mint]', errText(e)),
})
mintEnabled = true
} else {
@@ -229,7 +246,7 @@ async function main(): Promise<void> {
staticRoot: webRoot,
...(onRequest ? { onRequest } : {}),
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
onError: (e) => console.error('[browser-wss]', e),
onError: (e) => console.error('[browser-wss]', errText(e)),
})
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
@@ -245,7 +262,7 @@ async function main(): Promise<void> {
onApplied: (signal, hostsAffected) =>
console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`),
onDropped: () => console.warn('[revocation] dropped malformed kill-signal'),
onError: (e) => console.error('[revocation]', e),
onError: (e) => console.error('[revocation]', errText(e)),
})
console.log('\n=== relay-run Phase 1 READY ===')
@@ -267,7 +284,7 @@ async function main(): Promise<void> {
dp.listener.close()
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
} catch (e) {
console.error('[shutdown]', e)
console.error('[shutdown]', errText(e))
} finally {
process.exit(0)
}
@@ -277,6 +294,6 @@ async function main(): Promise<void> {
}
main().catch((e) => {
console.error('fatal:', e instanceof Error ? e.message : e)
console.error('fatal:', errText(e))
process.exit(1)
})

View File

@@ -22,8 +22,8 @@
* - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, timingSafeEqual } from 'node:crypto'
import { issueCapabilityToken, type AuthenticatedPrincipal } from 'relay-auth'
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import type { HostStore } from 'control-plane/src/store/ports.js'
@@ -37,12 +37,35 @@ const MAX_BODY_BYTES = 4096
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
/**
* F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short
* burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing
* OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries.
*/
export const MINT_RATE_BURST = 5
export const MINT_RATE_REFILL_PER_SEC = 0.2
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 163 chars, no leading/trailing '-'. */
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
/** The single CP `hosts` capability this route needs (interface segregation). */
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>
/**
* F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token
* bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to
* disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it.
*/
export interface MintRateLimit {
/** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */
readonly buckets: TokenBucketStore
/** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */
readonly salt: string
/** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */
readonly burst?: number
/** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */
readonly refillPerSec?: number
}
export interface AuthMintDeps {
/** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
readonly signingKey: CryptoKey
@@ -52,6 +75,8 @@ export interface AuthMintDeps {
readonly operatorPassword: string
/** Epoch-SECONDS clock (token iat/exp). */
readonly now: () => number
/** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */
readonly rateLimit?: MintRateLimit
/** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
readonly onError?: (e: unknown) => void
}
@@ -79,6 +104,23 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(ha, hb)
}
/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */
function mintBucketKey(salt: string, remoteAddr: string): string {
return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
}
/**
* F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed,
* false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to
* handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default).
*/
async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise<boolean> {
const key = mintBucketKey(rl.salt, remoteAddr)
const burst = rl.burst ?? MINT_RATE_BURST
const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC
return rl.buckets.take(key, refillPerSec, burst, now)
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, {
@@ -130,6 +172,16 @@ async function handleMint(
deps: AuthMintDeps,
): Promise<void> {
try {
// F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of
// OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored).
if (deps.rateLimit !== undefined) {
const remoteAddr = req.socket?.remoteAddress ?? ''
if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) {
sendJson(res, 429, { error: 'rate_limited' })
return
}
}
let raw: string
try {
raw = await readBody(req, MAX_BODY_BYTES)

View File

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

View File

@@ -0,0 +1,36 @@
/**
* B7 · Decode the browser's DPoP proof carried as an EXTRA WS subprotocol entry.
*
* A browser's native WebSocket API cannot set request headers, so relay-web offers the §4.3 DPoP
* proof-of-possession JWS as an additional `Sec-WebSocket-Protocol` entry
* `term.dpop.<base64url(proofJws)>`
* — mirroring how the capability token rides `term.token.<b64u>` (relay-contracts subprotocol.ts).
* This is the relay-side inverse of relay-web `encodeDpopSubprotocol`; the wire format is decoded
* byte-for-byte with the SAME isomorphic base64url helper both sides share (relay-contracts), so
* issuance and verification can never drift.
*
* FAIL-CLOSED (INV15): an absent entry OR malformed base64url / non-UTF-8 payload → `null`, so the
* downstream DPoP gate denies through the audited path instead of ever seeing a half-decoded proof.
*/
import { decodeBase64UrlString } from 'relay-contracts'
/** Subprotocol entry prefix carrying the DPoP proof (mirrors relay-web `DPOP_SUBPROTOCOL_PREFIX`). */
export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const
/**
* Extract the DPoP proof JWS from a parsed subprotocol list: find the first `term.dpop.` entry,
* strip the prefix, and base64url-decode the remainder. Returns `null` when the entry is absent,
* empty, or does not decode (deny-by-default).
*/
export function extractDpopProofFromSubprotocols(values: readonly string[]): string | null {
const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX))
if (entry === undefined) return null
const encoded = entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)
if (encoded.length === 0) return null
try {
const decoded = decodeBase64UrlString(encoded)
return decoded.length > 0 ? decoded : null
} catch {
return null // fail-closed: malformed base64url or invalid UTF-8
}
}