Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,15 +475,28 @@ export function runChecks(bundle: EvidenceBundle): CheckResult[] {
}

// ── AX 7: keyless flow ───────────────────────────────────────────────────
// PASSES on one existence proof: any sampled door answering 2xx with no
// key. Own-scope keyed doors (a /keys/me, a /usage) CORRECTLY 401 a
// keyless probe — their refusals are the declared contract, not evidence
// against keyless-first — so the observe side walks candidates until a
// keyless door is found (hinted-keyless first, escalation past the first
// wave, src/discovery.ts). The check therefore fails ONLY when NO walked
// door grants keyless access at all.
{
const succeeded = probes.filter((p) => p.status !== null && p.status >= 200 && p.status < 300)
const allAuthRefused = probes.length > 0 && probes.every((p) => p.status === 401 || p.status === 403)
checks.push(check('keyless-flow', 'at least one declared endpoint answers 2xx with no key', 7,
probes.map((p) => p.role),
probes.length === 0
? { verdict: 'fail', detail: 'no keyless GET candidates discoverable from agents.json/OpenAPI — nothing an agent can try without an account' }
: succeeded.length > 0
? pass(`${succeeded.length}/${probes.length} sampled endpoint(s) answered 2xx keyless (seed ${bundle.seed})`)
: { verdict: 'fail', detail: `all ${probes.length} sampled keyless candidates failed (statuses: ${probes.map((p) => p.status ?? 'ERR').join(', ')})` }))
: {
verdict: 'fail',
detail: allAuthRefused
? `no keyless access found: every sampled door (${probes.length}) demands a key (statuses: ${probes.map((p) => p.status ?? 'ERR').join(', ')}) — keyed doors answering 401 are legitimate, but at least one declared door must answer 2xx with no key`
: `all ${probes.length} sampled keyless candidates failed (statuses: ${probes.map((p) => p.status ?? 'ERR').join(', ')})`,
}))
}

// ── AX 8: 402 offers ─────────────────────────────────────────────────────
Expand Down
63 changes: 59 additions & 4 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,33 @@ function scrubAsMetadataEvidence(ev: Evidence | undefined): void {

export const MAX_KEYLESS_PROBES = 3

/**
* Escalation ceiling for the keyless sample (ax: keyless-flow robustness).
* The first wave is MAX_KEYLESS_PROBES doors; when NONE of them answers 2xx
* the sampler keeps walking the remaining candidates — a property with many
* own-scope KEYED doors (/keys/me, /usage — which CORRECTLY 401 keyless) must
* not fail keyless-flow just because the random three all landed on keyed
* doors while /listings answers 200 keyless. The walk stops at the FIRST
* keyless success (the check needs one existence proof, not a census) or at
* this ceiling (politeness: the escalation may spend at most
* MAX_KEYLESS_PROBE_TOTAL requests of the shared budget, and it only fires on
* surfaces where the cheap wave found no keyless door at all). A surface with
* genuinely NO keyless access probes up to the ceiling, every door refuses,
* and keyless-flow still FAILS — escalation widens the search, never the
* acceptance.
*/
export const MAX_KEYLESS_PROBE_TOTAL = 12

/**
* Card-declared per-endpoint auth hints (agents.json interfaces.http[].auth)
* that mark a door EXPLICITLY keyless. Honored twice: a hinted-keyless door
* sorts to the FRONT of the probe order (the strongest candidates are tried
* first), and a door hinted as anything else (apiKey, bearer, oauth…) is
* excluded from the keyless candidate pool entirely (its 401 is the declared
* contract, not evidence against keyless-first).
*/
export const KEYLESS_AUTH_HINT_RE = /none|keyless|public/i

/**
* Upper bound on requests the contract-diff pass (step 5, below) will itself
* fire, independent of however much of the shared politeness budget happens
Expand Down Expand Up @@ -1830,16 +1857,42 @@ export async function observeTarget(
// 2. Seeded endpoint sampling — which endpoints get probed is not
// predictable before the run (the seed is fresh), but fully replayable
// after it (the seed is in the report).
const templated = (p: string) => p.includes('{') || p.includes('%7B') // URL templates aren't probeable
const candidatePaths = dedupe([
...openapi.probeCandidates.map((c) => c.path),
...agents.endpoints
.filter((e) => e.method === 'GET' && (!e.auth || /none|keyless|public/i.test(e.auth)))
.filter((e) => e.method === 'GET' && (!e.auth || KEYLESS_AUTH_HINT_RE.test(e.auth)))
.map((e) => pathOf(e.url, origin))
.filter((p): p is string => p !== undefined),
])
.filter((p) => !p.includes('{') && !p.includes('%7B')) // URL templates aren't probeable
.filter((p) => !templated(p))
.sort()
for (const path of sampleSeeded(candidatePaths, MAX_KEYLESS_PROBES, seed)) {
// Doors the card EXPLICITLY hints keyless (interfaces.http[].auth "none"/
// "keyless"/"public") are the strongest candidates: they go FIRST in the
// probe order, so a card that hints its doors resolves keyless-flow inside
// the cheap first wave. Unhinted candidates (openapi GETs with no security,
// interfaces.http entries with no auth member) follow in seeded order.
const hintedKeyless = new Set(
agents.endpoints
.filter((e) => e.method === 'GET' && typeof e.auth === 'string' && KEYLESS_AUTH_HINT_RE.test(e.auth))
.map((e) => pathOf(e.url, origin))
.filter((p): p is string => p !== undefined && !templated(p) && candidatePaths.includes(p)),
)
const probeOrder = [
...sampleSeeded([...hintedKeyless].sort(), hintedKeyless.size, seed),
...sampleSeeded(candidatePaths.filter((p) => !hintedKeyless.has(p)), candidatePaths.length, seed),
]
// First wave: MAX_KEYLESS_PROBES doors (unchanged evidence volume for the
// sibling checks that read the sample). Escalation: while NO door has
// answered 2xx keyless yet, keep walking the remaining candidates up to
// MAX_KEYLESS_PROBE_TOTAL — so keyless-flow can only fail a surface where
// every walked door refused, never one where the random wave happened to
// land on own-scope keyed doors. Stops at the first keyless success.
let keylessProbed = 0
let keylessFound = false
for (const path of probeOrder) {
if (keylessProbed >= MAX_KEYLESS_PROBE_TOTAL) break
if (keylessProbed >= MAX_KEYLESS_PROBES && keylessFound) break
Comment on lines +1893 to +1895

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve budget before expanding keyless probes

When an endpoint-rich target needs all 12 attempts before finding a keyless success, this loop consumes nine more requests than the previous sampler before the offer, MCP OAuth, registry, and other fixed high-value probes run. With the default 32-request Observer budget, a target that also publishes three face alternates, pricing/probe metadata, typed-body candidates, and an offer reaches the budget before the MCP chain, producing status:null evidence and false failures for otherwise valid independent features. Cap this phase according to the remaining reserved budget or move escalation after the fixed probes.

Useful? React with 👍 / 👎.

// Candidate paths are CARD-DERIVED: openapi path keys are raw attacker
// input, and a key that does NOT begin with "/" (e.g. "@evil.example/x" or
// ".evil.example/x") makes `${origin}${path}` resolve OFF-ORIGIN. The
Expand All @@ -1849,7 +1902,9 @@ export async function observeTarget(
// origin and pass unchanged.
const url = `${origin}${path}`
if (!isPubliclyRoutableSameOrigin(url, origin)) continue
await observer.observe(ROLE.keyless('GET', path), url, { accept: 'application/json' })
const ev = await observer.observe(ROLE.keyless('GET', path), url, { accept: 'application/json' })
keylessProbed += 1
if (ev.status !== null && ev.status >= 200 && ev.status < 300) keylessFound = true
}

// 2c. Clause-3 typed-body legibility beyond the root (ax-fsg): a target that
Expand Down
165 changes: 147 additions & 18 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,86 @@ export function unavailableExecRunner(reason: string = RUNNER_UNAVAILABLE_NO_BIN
// The parent-side egress gateway — the floor, applied where the parent owns it
// ---------------------------------------------------------------------------

/**
* THE CONNECTION BUDGET (ax: burst-vs-sibling isolation). A workerd isolate
* may hold only ~6 simultaneous open connections; past the budget the runtime
* force-closes the least-recently-used open response body, which surfaces to
* whoever was reading it as "Response closed due to connection limit". A suite
* test that BURSTS concurrent fetches (a 429 rate-limit test firing dozens of
* requests, bodies never read) would otherwise exhaust the budget and corrupt
* a SIBLING test's plain GET-and-parse mid-read — the verdict would then
* reflect the runner's plumbing, not the target's behavior. Two guards, both
* applied per isolate AND at the parent gateway:
*
* 1. A SEMAPHORE bounds in-flight fetches to EXEC_MAX_CONCURRENT_FETCHES
* (headroom under the budget); excess fetches QUEUE, they are never
* refused — the suite's assertions see the same statuses/bodies, only
* the wire-level concurrency is shaped.
* 2. Every non-stream response body is FULLY BUFFERED while the slot is
* held (`bufferResponse`), so an unread body can never pin a connection
* after its fetch resolves. `text/event-stream` responses pass through
* as live streams (buffering one would hang until the wall breaker) and
* release their slot on arrival.
*
* Neither guard changes what a suite test can assert: status, statusText,
* headers, url, and body bytes are preserved verbatim.
*/
export const EXEC_MAX_CONCURRENT_FETCHES = 5

/** A tiny FIFO semaphore bounding concurrent in-flight fetches. */
export interface FetchLimiter {
acquire(): Promise<void>
release(): void
}

export function createFetchLimiter(max: number = EXEC_MAX_CONCURRENT_FETCHES): FetchLimiter {
let inFlight = 0
const waiters: Array<() => void> = []
return {
acquire(): Promise<void> {
if (inFlight < max) {
inFlight += 1
return Promise.resolve()
}
return new Promise((resolve) => waiters.push(resolve))
},
release(): void {
const next = waiters.shift()
if (next !== undefined) next() // the slot transfers; inFlight is unchanged
else inFlight -= 1
},
}
}

/** Statuses the Response constructor refuses a body for (RFC 9110 null-body). */
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304])

/**
* Read a response FULLY into memory and hand back a memory-backed equivalent
* — same status, statusText, headers, url, and body bytes — so the underlying
* connection is consumed and closed the moment the fetch resolves, whether or
* not the suite ever reads the body. `text/event-stream` responses are
* returned untouched (a live stream must stay live). This is the half of the
* connection-budget guard that makes an UNREAD body harmless.
*/
export async function bufferResponse(res: Response): Promise<Response> {
const ctype = (res.headers.get('content-type') ?? '').toLowerCase()
if (ctype.includes('text/event-stream')) return res
if (res.body === null) return res
Comment on lines +190 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep event streams inside the connection budget

When a suite opens several text/event-stream responses and leaves them live, this early return bypasses buffering while gatewayFetch still releases the semaphore slot in its finally block; the generated isolate wrapper does the same. Consequently any number of live SSE connections can remain open while later requests continue, so six concurrent streams can still exhaust the workerd connection limit and corrupt or close a sibling response—the exact failure this limiter is intended to prevent. Streaming bodies need to retain or otherwise account for their slot until cancellation or closure.

Useful? React with 👍 / 👎.

const buf = await res.arrayBuffer()
Comment on lines +189 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid fully consuming open-ended non-SSE responses

For a streaming endpoint that is not labeled text/event-stream—for example an NDJSON feed, chunked download, or response with no content type—fetch() now waits for arrayBuffer() to reach EOF before returning any Response. A suite that only checks the response status previously completed as soon as headers arrived, but now hangs until the five-minute wall breaker and is falsely failed; large finite bodies also get unconditionally materialized in memory. The buffering guard needs a bounded or streaming-safe strategy rather than consuming every non-SSE body to completion.

Useful? React with 👍 / 👎.

const out = new Response(NULL_BODY_STATUSES.has(res.status) ? null : buf, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
})
try {
Object.defineProperty(out, 'url', { value: res.url })
} catch {
// A runtime with a non-configurable url getter keeps its own value.
}
return out
}

/**
* Marker header stamped on every gateway-refused response: `"violation"` when
* the floor/verb gate refused (a `GateViolation` was recorded), `"error"` when
Expand Down Expand Up @@ -158,21 +238,27 @@ export const GATEWAY_MARKER_HEADER = 'x-apiqa-gateway'
export async function gatewayFetch(
request: Request,
realFetch: (url: string, init?: RequestInit) => Promise<Response> = (url, init) => fetch(url, init),
opts: { sandbox?: boolean; violations?: GateViolation[] } = {},
opts: { sandbox?: boolean; violations?: GateViolation[]; limiter?: FetchLimiter } = {},
): Promise<Response> {
const violations = opts.violations ?? []
const before = violations.length
const gated = createGatedFetch({ realFetch, sandbox: opts.sandbox ?? true, violations })
// The connection-budget guard, parent side: bound in-flight upstream
// fetches and consume every non-stream body while the slot is held — a
// suite burst can queue here, it can never pin unread upstream connections
// against the parent isolate's budget (see EXEC_MAX_CONCURRENT_FETCHES).
if (opts.limiter !== undefined) await opts.limiter.acquire()
try {
const headers: Record<string, string> = {}
request.headers.forEach((v, k) => {
headers[k] = v
})
return await gated(request.url, {
const res = await gated(request.url, {
method: request.method,
headers,
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text(),
})
return await bufferResponse(res)
} catch (err) {
return new Response(
JSON.stringify({ type: 'BLOCKED', reason: err instanceof Error ? err.message : String(err) }),
Expand All @@ -184,6 +270,8 @@ export async function gatewayFetch(
},
},
)
} finally {
opts.limiter?.release()
}
}

Expand All @@ -206,8 +294,12 @@ export function createOutboundGateway(
opts: { sandbox?: boolean } = {},
): OutboundGatewayLike {
const violations: GateViolation[] = []
// ONE limiter per gateway instance: the module-level deploy gateway is
// shared across concurrent runs, so this bound is what actually protects
// the parent isolate's connection budget from ANY combination of suites.
const limiter = createFetchLimiter()
return {
fetch: (request: Request) => gatewayFetch(request, realFetch, { ...opts, violations }),
fetch: (request: Request) => gatewayFetch(request, realFetch, { ...opts, violations, limiter }),
drainViolations: () => violations.splice(0, violations.length),
}
}
Expand Down Expand Up @@ -242,6 +334,7 @@ const DOCUMENT = ${JSON.stringify(req.artifactKind === 'document')}
const HAS_MODULE = ${JSON.stringify(hasModule)}
const EXPORT_NAME = ${JSON.stringify(req.exportName ?? null)}
const MARKER = ${JSON.stringify(GATEWAY_MARKER_HEADER)}
const MAX_CONCURRENT_FETCHES = ${JSON.stringify(EXEC_MAX_CONCURRENT_FETCHES)}

// Captured at entry evaluation — BEFORE any suite byte runs — so suite code
// patching Response/JSON cannot forge the body the parent folds. (The
Expand All @@ -256,6 +349,29 @@ export default {
globalThis[${JSON.stringify('__APIQA_VITEST_RUNS__')}] = { [${JSON.stringify(HOSTED_RUN_ID)}]: { api: harness.api } }
Math.random = seededRandom(SEED)
const realFetch = globalThis.fetch.bind(globalThis)
// THE CONNECTION-BUDGET GUARD, isolate side: workerd holds ~6 simultaneous
// connections and force-closes the least-recently-used open body past the
// budget — so a test bursting concurrent fetches (bodies never read) must
// not be able to truncate a SIBLING test's response mid-parse. In-flight
// fetches are bounded by a FIFO semaphore (excess QUEUES, nothing is
// refused), and every non-stream body is fully buffered while the slot is
// held, so an unread response can never pin a connection. Event streams
// pass through live. Status/headers/url/bytes are preserved verbatim —
// the suite's assertions are untouched, only wire concurrency is shaped.
let inFlightFetches = 0
const fetchWaiters = []
const acquireFetchSlot = () => {
if (inFlightFetches < MAX_CONCURRENT_FETCHES) {
inFlightFetches += 1
return Promise.resolve()
}
return new Promise((resolve) => fetchWaiters.push(resolve))
}
const releaseFetchSlot = () => {
const next = fetchWaiters.shift()
if (next !== undefined) next()
else inFlightFetches -= 1
}
globalThis.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : String(input && input.url ? input.url : input)
const method = ((init && init.method) || (input && input.method) || 'GET').toUpperCase()
Expand All @@ -264,23 +380,36 @@ export default {
violations.push({ url, reason })
throw new Error(reason)
}
const res = await realFetch(input, init)
// Every egress rides globalOutbound = the parent gateway; a marked 403
// is the gateway's refusal. Record it (violation ⇒ fails the run even
// if caught) and THROW — the same shape the local gated fetch gives.
if (res.status === 403) {
const marker = res.headers.get(MARKER)
if (marker !== null) {
let reason = 'refused by the egress gateway'
try {
const body = await res.clone().json()
if (body && typeof body.reason === 'string') reason = body.reason
} catch {}
if (marker === 'violation') violations.push({ url, reason })
throw new Error(reason)
await acquireFetchSlot()
try {
const res = await realFetch(input, init)
const ctype = (res.headers.get('content-type') || '').toLowerCase()
// A live event stream stays live (buffering would hang to the wall
// breaker); its slot frees on arrival, the stream itself rides on.
if (ctype.indexOf('text/event-stream') !== -1) return res
const buf = res.body === null ? null : await res.arrayBuffer()
// Every egress rides globalOutbound = the parent gateway; a marked 403
// is the gateway's refusal. Record it (violation ⇒ fails the run even
// if caught) and THROW — the same shape the local gated fetch gives.
if (res.status === 403) {
const marker = res.headers.get(MARKER)
if (marker !== null) {
let reason = 'refused by the egress gateway'
try {
const body = JSON.parse(new TextDecoder().decode(buf))
if (body && typeof body.reason === 'string') reason = body.reason
} catch {}
if (marker === 'violation') violations.push({ url, reason })
throw new Error(reason)
}
}
const nullBody = res.status === 101 || res.status === 204 || res.status === 205 || res.status === 304
const out = new Response(nullBody ? null : buf, { status: res.status, statusText: res.statusText, headers: res.headers })
try { Object.defineProperty(out, 'url', { value: res.url }) } catch {}
return out
} finally {
releaseFetchSlot()
}
return res
}
try {
if (DOCUMENT) for (const n of SUBSET_GLOBALS) globalThis[n] = harness.api[n]
Expand Down
Loading
Loading