mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user