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:
@@ -9,10 +9,13 @@ import { Readable } from 'node:stream'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { HostRecord } from 'control-plane/src/model/records.js'
|
||||
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||
import type { TokenBucketStore } from 'relay-auth'
|
||||
import {
|
||||
createAuthMintRoute,
|
||||
loadSigningKeyFromEnv,
|
||||
MINT_RATE_BURST,
|
||||
type SubdomainHostLookup,
|
||||
type MintRateLimit,
|
||||
} from '../src/servers/auth-mint.js'
|
||||
|
||||
const subtle = globalThis.crypto.subtle
|
||||
@@ -86,11 +89,12 @@ function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<
|
||||
return { res: captured as unknown as ServerResponse, captured, done }
|
||||
}
|
||||
|
||||
function fakeReq(method: string, url: string, body?: string): IncomingMessage {
|
||||
function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage {
|
||||
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
|
||||
const req = Readable.from(chunks) as unknown as IncomingMessage
|
||||
;(req as { method?: string }).method = method
|
||||
;(req as { url?: string }).url = url
|
||||
;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr }
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -220,6 +224,96 @@ describe('createAuthMintRoute — routing semantics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── F1 · per-IP mint throttle ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */
|
||||
function inMemoryBucket(): TokenBucketStore {
|
||||
const state = new Map<string, { tokens: number; ts: number }>()
|
||||
return {
|
||||
async take(key, refillPerSec, burst, now) {
|
||||
const s = state.get(key) ?? { tokens: burst, ts: now }
|
||||
const elapsed = now - s.ts
|
||||
let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens
|
||||
const allowed = tokens >= 1
|
||||
if (allowed) tokens -= 1
|
||||
state.set(key, { tokens, ts: now })
|
||||
return allowed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mkRouteRl(
|
||||
hosts: SubdomainHostLookup,
|
||||
rateLimit: MintRateLimit,
|
||||
): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||
return createAuthMintRoute({
|
||||
signingKey: keys.signingKey,
|
||||
hosts,
|
||||
operatorPassword: PASSWORD,
|
||||
now: () => NOW,
|
||||
rateLimit,
|
||||
onError: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
async function postRl(
|
||||
route: (req: IncomingMessage, res: ServerResponse) => boolean,
|
||||
bodyObj: unknown,
|
||||
remoteAddr: string,
|
||||
): Promise<CapturedRes> {
|
||||
const { res, captured, done } = fakeRes()
|
||||
route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res)
|
||||
await done
|
||||
return captured
|
||||
}
|
||||
|
||||
describe('createAuthMintRoute — F1 per-IP throttle', () => {
|
||||
const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' }
|
||||
|
||||
it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => {
|
||||
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' })
|
||||
const ip = '198.51.100.4'
|
||||
for (let i = 0; i < MINT_RATE_BURST; i++) {
|
||||
const ok = await postRl(route, GOOD, ip)
|
||||
expect(ok.statusCode).toBe(200)
|
||||
}
|
||||
const throttled = await postRl(route, GOOD, ip)
|
||||
expect(throttled.statusCode).toBe(429)
|
||||
expect(throttled.body).not.toContain('token')
|
||||
})
|
||||
|
||||
it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => {
|
||||
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
|
||||
const ip = '198.51.100.9'
|
||||
expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token
|
||||
const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip)
|
||||
expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check
|
||||
})
|
||||
|
||||
it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => {
|
||||
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
|
||||
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200)
|
||||
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted
|
||||
expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected
|
||||
})
|
||||
|
||||
it('keys the bucket on a SALTED HASH, never the raw client IP', async () => {
|
||||
const seenKeys: string[] = []
|
||||
const spyBucket: TokenBucketStore = {
|
||||
async take(key) {
|
||||
seenKeys.push(key)
|
||||
return true
|
||||
},
|
||||
}
|
||||
const rawIp = '203.0.113.55'
|
||||
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' })
|
||||
await postRl(route, GOOD, rawIp)
|
||||
expect(seenKeys).toHaveLength(1)
|
||||
expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/)
|
||||
expect(seenKeys[0]).not.toContain(rawIp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSigningKeyFromEnv', () => {
|
||||
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
|
||||
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
|
||||
|
||||
68
relay-run/tests/browser-server.test.ts
Normal file
68
relay-run/tests/browser-server.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* B7 · unit tests for `buildUpgradeRequest` — the browser upgrade → P1 `UpgradeRequest` builder.
|
||||
* Focus: the DPoP proof transport (FUNCTIONAL BLOCKER). A browser cannot set the `dpop` request
|
||||
* header, so the proof rides the `term.dpop.<b64u>` subprotocol entry; the builder must read it there
|
||||
* while keeping the header path working (header WINS if both). Also pins `activeSessionCount` plumbing.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { encodeBase64UrlString, APP_SUBPROTOCOL } from 'relay-contracts'
|
||||
import { buildUpgradeRequest } from '../src/servers/browser-server.js'
|
||||
import { DPOP_SUBPROTOCOL_PREFIX } from '../src/servers/dpop-subprotocol.js'
|
||||
|
||||
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
||||
|
||||
function dpopEntry(proofJws: string): string {
|
||||
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
||||
}
|
||||
|
||||
function mkReq(headers: Record<string, string | undefined>): IncomingMessage {
|
||||
return {
|
||||
headers: { host: 'alice.example.com', ...headers },
|
||||
url: '/ws',
|
||||
socket: { remoteAddress: '1.2.3.4' },
|
||||
} as unknown as IncomingMessage
|
||||
}
|
||||
|
||||
describe('buildUpgradeRequest — DPoP transport', () => {
|
||||
it('reads the proof from the term.dpop.<b64u> subprotocol when no header is set (the browser path)', () => {
|
||||
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
||||
const upgrade = buildUpgradeRequest(req, 0)
|
||||
expect(upgrade.dpop.proof).toBe(PROOF)
|
||||
})
|
||||
|
||||
it('prefers the dpop HEADER over the subprotocol entry when both are present (header wins)', () => {
|
||||
const req = mkReq({
|
||||
dpop: 'header-proof-jws',
|
||||
'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}`,
|
||||
})
|
||||
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-proof-jws')
|
||||
})
|
||||
|
||||
it('still honours the dpop header alone (header path unchanged)', () => {
|
||||
const req = mkReq({ dpop: 'header-only', 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-only')
|
||||
})
|
||||
|
||||
it('yields proof=null when neither header nor subprotocol carries a proof', () => {
|
||||
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
||||
})
|
||||
|
||||
it('yields proof=null (fail-closed) for a malformed term.dpop. subprotocol entry', () => {
|
||||
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${DPOP_SUBPROTOCOL_PREFIX}not*b64u!` })
|
||||
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the comma-separated subprotocol list and always carries the app subprotocol through', () => {
|
||||
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
||||
expect(buildUpgradeRequest(req, 0).subprotocols).toContain(APP_SUBPROTOCOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUpgradeRequest — activeSessionCount plumbing (F2)', () => {
|
||||
it('carries the caller-supplied active-session count verbatim', () => {
|
||||
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
||||
expect(buildUpgradeRequest(req, 3).activeSessionCount).toBe(3)
|
||||
})
|
||||
})
|
||||
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* B7 · unit tests for the relay-side DPoP subprotocol decoder (`extractDpopProofFromSubprotocols`).
|
||||
* The browser (relay-web) offers the DPoP proof as an extra `term.dpop.<b64u(proofJws)>` entry on
|
||||
* the WS upgrade because native WebSocket cannot set request headers. These tests pin the wire
|
||||
* contract (valid decodes; absent → null; malformed → null, fail-closed) against the SAME isomorphic
|
||||
* base64url helper relay-web encodes with (relay-contracts), so the two sides agree byte-for-byte.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { encodeBase64UrlString } from 'relay-contracts'
|
||||
import {
|
||||
extractDpopProofFromSubprotocols,
|
||||
DPOP_SUBPROTOCOL_PREFIX,
|
||||
} from '../src/servers/dpop-subprotocol.js'
|
||||
import { APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX } from 'relay-contracts'
|
||||
|
||||
/** Build the wire entry exactly as relay-web `encodeDpopSubprotocol` does. */
|
||||
function dpopEntry(proofJws: string): string {
|
||||
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
||||
}
|
||||
|
||||
// A representative three-part DPoP proof JWS (header.payload.sig) — content is opaque to the decoder.
|
||||
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — valid', () => {
|
||||
it('decodes a term.dpop.<b64u> entry back to the exact proof JWS', () => {
|
||||
const subprotocols = [APP_SUBPROTOCOL, dpopEntry(PROOF)]
|
||||
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||
})
|
||||
|
||||
it('finds the DPoP entry regardless of position (after app + token entries)', () => {
|
||||
const subprotocols = [
|
||||
APP_SUBPROTOCOL,
|
||||
TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString('v4.public.raw-token'),
|
||||
dpopEntry(PROOF),
|
||||
]
|
||||
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — absent → null', () => {
|
||||
it('returns null when no term.dpop. entry is present', () => {
|
||||
expect(extractDpopProofFromSubprotocols([APP_SUBPROTOCOL])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an empty list', () => {
|
||||
expect(extractDpopProofFromSubprotocols([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the prefix is present but the payload is empty', () => {
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — malformed → null (fail-closed)', () => {
|
||||
it('returns null for a non-base64url payload (illegal characters)', () => {
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + 'not*base64url!'])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for base64url bytes that are not valid UTF-8', () => {
|
||||
// 0xFF 0xFE is not a valid UTF-8 sequence; base64url of those two bytes is "__4".
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + '__4'])).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user