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

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