fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships

Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.

Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.

Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.

Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.

New migration 0195 adds the connection anchor and links access tokens to it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 15:09:11 +02:00
1 parent 06363771ee
commit 22eb9cba0d
39 files changed
+1826 -72

No files matched your search

@@ -0,0 +1,68 @@
/**
* Bounded body reader (M3) — reject oversized bodies WITHOUT buffering them.
*/
import { describe, it, expect } from "vitest";
import { readBodyCapped } from "../read-capped";
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
});
}
const enc = (s: string) => new TextEncoder().encode(s);
describe("readBodyCapped", () => {
it("returns the decoded text under the cap", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: streamOf([enc("hello "), enc("world")]) },
1024,
);
expect(res).toEqual({ ok: true, text: "hello world" });
});
it("rejects up front on an oversized Content-Length without consuming the body", async () => {
const body = streamOf([enc("x")]);
let getReaderCalled = false;
const original = body.getReader.bind(body);
body.getReader = ((...args: unknown[]) => {
getReaderCalled = true;
// @ts-expect-error — passthrough spy
return original(...args);
}) as typeof body.getReader;
const res = await readBodyCapped(
{ headers: new Headers({ "content-length": "999999" }), body },
16,
);
expect(res).toEqual({ ok: false, reason: "too_large" });
expect(getReaderCalled).toBe(false);
});
it("aborts mid-stream the moment the running total exceeds the cap", async () => {
let chunksPulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(c) {
chunksPulled++;
// 10 bytes per chunk; cap is 16, so the 2nd chunk overflows.
c.enqueue(enc("0123456789"));
},
});
const res = await readBodyCapped({ headers: new Headers(), body }, 16);
expect(res).toEqual({ ok: false, reason: "too_large" });
// It must stop pulling once it overflows, not drain the (infinite) stream.
expect(chunksPulled).toBeLessThanOrEqual(2);
});
it("treats a null body as empty", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: null },
16,
);
expect(res).toEqual({ ok: true, text: "" });
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Bounded body reader (M3).
*
* `await res.text()` / `await request.text()` buffer the WHOLE body before any
* size check runs, so a hostile peer can stream (or decompress) gigabytes into
* memory for the duration of the request timeout before a post-hoc
* `raw.length > MAX` rejection fires. This helper enforces the cap WHILE
* reading: it rejects up front on an oversized `Content-Length`, then streams
* the body and aborts the moment the running byte count exceeds the ceiling.
*
* Returns `{ ok: false }` on overflow (the caller maps it to its own error);
* returns the decoded UTF-8 text on success.
*/
export type CappedReadResult =
| { ok: true; text: string }
| { ok: false; reason: "too_large" };
export async function readBodyCapped(
source: { headers: Headers; body: ReadableStream<Uint8Array> | null },
maxBytes: number,
): Promise<CappedReadResult> {
// Up-front reject on a declared oversized length — cheap and stops the read
// before a single byte is buffered.
const declared = source.headers.get("content-length");
if (declared) {
const n = Number(declared);
if (Number.isFinite(n) && n > maxBytes) {
return { ok: false, reason: "too_large" };
}
}
const body = source.body;
if (!body) return { ok: true, text: "" };
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maxBytes) {
// Stop pulling immediately — do not buffer past the ceiling.
await reader.cancel().catch(() => {});
return { ok: false, reason: "too_large" };
}
chunks.push(value);
}
} finally {
reader.releaseLock?.();
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return { ok: true, text: new TextDecoder("utf-8").decode(merged) };
}