mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
69 lines
2.2 KiB
TypeScript
69 lines
2.2 KiB
TypeScript
/**
|
|
* 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: "" });
|
|
});
|
|
});
|