Files
web-terminal/relay-auth/src/enforce/onUpgrade.ts
Yaojia Wang a09c131539 fix(relay-auth): close 5 pre-production security findings (F1–F5)
F1 (HIGH): bind step-up freshness to the required method (stepUpMethod) so a
  login-time or weaker (TOTP) factor can no longer satisfy a passkey requirement.
F2 (HIGH): make the step-up gate host-driven and fail-closed on connect+reattach
  (deny when policy.required and the principal is missing/stale/wrong-method).
F3 (MED): bind the WebAuthn assertion to the stored credential (credentialId
  cross-check + forward publicKey/credentialId to the verifier).
F4 (LOW): make verifyDpopProof totally fail-safe (no unhandled throw), wrap the
  authz boundary into a clean audited 401, and validate cnfJkt format at issue.
F5 (LOW): return newSignCount from finishAuthentication so the caller can persist
  the advanced counter (WebAuthn clone detection).

All fixes ship regression tests. relay-auth: 122 tests green, tsc clean.
2026-07-02 16:41:18 +02:00

191 lines
6.5 KiB
TypeScript

/**
* T12 · Enforcement entry point `onUpgrade` — the SOLE owner of the WS-upgrade authorization
* DECISION (INDEX §6a). P1's `authorizeUpgrade` is a thin adapter that DELEGATES here. The full
* decision composes, in order: Origin/CSWSH (retained, INV15) → pre-auth throttle (Finding-5) →
* deny-by-default authz incl. DPoP proof-of-possession + cross-tenant gate (T3/INV1) → per-tenant
* rate → step-up gate (T8/Finding-3, v0.10) → single-use jti burn (Finding-4) → audit (T4/INV10).
*
* Deny-by-default: every branch that is not an explicit allow returns 401/403 and emits exactly one
* `deny` AuditEvent. No allow path writes payload (INV10).
*/
import type { CapabilityRight, HostRecord } from 'relay-contracts'
import type {
AuditAction,
AuthenticatedPrincipal,
AuditSink,
HostRegistryPort,
RevocationStore,
SessionRegistryPort,
StepUpPolicy,
TokenBucketStore,
} from '../types.js'
import { authorizeConnect, authorizeReattach, type AuthzOutcome } from '../authz/decide.js'
import type { DpopContext } from '../capability/verify.js'
import { peekExp } from '../capability/verify.js'
import {
checkPreAuthRate,
checkConnectRate,
checkConcurrentSessions,
policyForPlan,
} from '../ratelimit/quota.js'
import { needsStepUp } from '../human/stepup/stepup.js'
import { buildAuditEvent, audit } from '../audit/log.js'
export interface UpgradeContext {
readonly capabilityRaw: string
readonly originHeader: string
readonly expectedAud: string
readonly requestedHostId: string
readonly requiredRight: CapabilityRight
readonly remoteAddrHash: string
readonly activeSessionCount: number
readonly dpop: DpopContext
readonly principal: AuthenticatedPrincipal | null // session principal for step-up (T8)
}
export interface EnforceDeps {
readonly hosts: HostRegistryPort
readonly sessions: SessionRegistryPort
readonly revocation: RevocationStore
readonly buckets: TokenBucketStore
readonly audit: AuditSink
/** v0.10; v0.9 injects a "never required" policy (only consulted when a session principal exists). */
readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy
}
/** v0.9 default: no per-host tier lookup port here; the real tier comes from P3's account registry
* (INTEGRATION POINT). A conservative fixed policy bounds per-tenant abuse until then. */
const DEFAULT_TENANT_POLICY = policyForPlan('personal')
function deny(status: 401 | 403, reason: string): AuthzOutcome {
return { ok: false, status, reason }
}
function auditActionFor(base: AuditAction, reason: string): AuditAction {
return reason === 'cross_tenant' || reason === 'cross_tenant_session' ? 'cross-tenant-attempt' : base
}
async function emitDeny(
deps: EnforceDeps,
ctx: UpgradeContext,
base: AuditAction,
reason: string,
now: number,
): Promise<void> {
await audit(
deps.audit,
buildAuditEvent({
action: auditActionFor(base, reason),
principal: ctx.principal,
hostId: ctx.requestedHostId,
sessionId: null,
jti: null,
outcome: 'deny',
reason,
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
}
/** Shared enforcement pipeline for connect (`base='attach'`) and reattach (`base='reattach'`). */
export async function runEnforcement(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
authorize: () => Promise<AuthzOutcome>,
base: AuditAction,
sessionId: string | null,
): Promise<AuthzOutcome> {
// 1. Origin / CSWSH (retained base-app check, INV15).
if (!allowedOrigins.includes(ctx.originHeader)) {
await emitDeny(deps, ctx, base, 'bad_origin', now)
return deny(401, 'bad_origin')
}
// 2. Pre-auth throttle BEFORE any token work (Finding-5) — keyed on the IP hash, no principal yet.
if (!(await checkPreAuthRate(ctx.remoteAddrHash, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'pre_auth_throttled', now)
return deny(403, 'pre_auth_throttled')
}
// 3. Deny-by-default authz (token verify + DPoP PoP + INV1 cross-tenant gate).
const outcome = await authorize()
if (!outcome.ok) {
await emitDeny(deps, ctx, base, outcome.reason, now)
return outcome
}
const accountId = outcome.principal.accountId
// 4. Per-tenant rate (after the principal is known).
if (!(await checkConnectRate(accountId, DEFAULT_TENANT_POLICY, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'rate_limited', now)
return deny(403, 'rate_limited')
}
if (!checkConcurrentSessions(accountId, ctx.activeSessionCount, DEFAULT_TENANT_POLICY)) {
await emitDeny(deps, ctx, base, 'too_many_sessions', now)
return deny(403, 'too_many_sessions')
}
// 5. Step-up gate (Finding-3/F1/F2) — HOST-DRIVEN and FAIL-CLOSED on connect AND reattach.
// When a host REQUIRES step-up, DENY unless an authenticated principal proves a FRESH step-up
// of the required method. A missing host, a null principal, or a stale/wrong-method step-up all
// fail closed. When the policy does not require step-up, this gate is a no-op.
const host = await deps.hosts.getById(outcome.hostId)
const policy = deps.stepUpPolicyFor(host as HostRecord)
if (policy.required) {
if (host === null || ctx.principal === null || needsStepUp(ctx.principal, policy, now)) {
await emitDeny(deps, ctx, 'stepup', 'step_up_required', now)
return deny(403, 'step_up_required')
}
}
// 6. Single-use consume (Finding-4) — burn the jti on the first fully-allowed upgrade.
const exp = peekExp(ctx.capabilityRaw)
if (!(await deps.revocation.consumeOnce(outcome.jti, exp))) {
await emitDeny(deps, ctx, base, 'token_replayed', now)
return deny(403, 'token_replayed')
}
// 7. Audit allow + return.
await audit(
deps.audit,
buildAuditEvent({
action: base,
principal: outcome.principal,
hostId: outcome.hostId,
sessionId,
jti: outcome.jti,
outcome: 'allow',
reason: 'ok',
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
return outcome
}
export function onUpgrade(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
): Promise<AuthzOutcome> {
return runEnforcement(
ctx,
deps,
allowedOrigins,
now,
() =>
authorizeConnect(
{
capabilityRaw: ctx.capabilityRaw,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
},
ctx.dpop,
deps.hosts,
deps.revocation,
now,
),
'attach',
null,
)
}