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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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, 1–63 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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
36
relay-run/src/servers/dpop-subprotocol.ts
Normal file
36
relay-run/src/servers/dpop-subprotocol.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user