security: close SEC-H4/H6 gaps from second-pass review

- SEC-H6: remove conditional audience check in mcp/index.ts — audience is
  now always enforced against the mcpResource URL. Add migration to revoke
  pre-existing oauth_tokens with audience=NULL so dead rows don't linger.
- SEC-H4: validate doc.issuer against config.issuer inside discover() to
  prevent a MITM'd discovery doc from supplying a crafted expected issuer.
  verifyIdToken caller now passes config.issuer as ground truth, not
  doc.issuer.
- tests: cover three new OIDC callback failure paths (no_id_token,
  id_token_invalid, subject_mismatch) and two idempotency caps (key length
  >128 chars returns 400, body >256 KiB skips caching).
This commit is contained in:
jubnl
2026-04-20 21:35:30 +02:00
parent 9f57ab4517
commit 20bf9c2312
7 changed files with 92 additions and 13 deletions
@@ -134,6 +134,35 @@ describe('applyIdempotency', () => {
expect(rows['fail-key:3:POST:/api/test']).toBeUndefined();
});
it('returns 400 when X-Idempotency-Key exceeds 128 characters', () => {
const longKey = 'a'.repeat(129);
const req = makeReq('POST', { 'x-idempotency-key': longKey });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).not.toHaveBeenCalled();
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('128') }),
);
});
it('does NOT cache response body exceeding 256 KiB', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'big-key' });
const res = makeRes(200);
const originalJsonSpy = res.json as ReturnType<typeof vi.fn>;
const largePayload = { data: 'x'.repeat(256 * 1024 + 1) };
const next = vi.fn(() => {
// res.json is now the wrapper; calling it exercises the size-cap branch
res.json(largePayload);
});
applyIdempotency(req, res, next, 5);
expect(next).toHaveBeenCalledOnce();
// Underlying spy was called (response reached the client)
expect(originalJsonSpy).toHaveBeenCalledWith(largePayload);
// But NOT stored in the idempotency store
expect(rows['big-key:5:POST:/api/test']).toBeUndefined();
});
it('handles PUT, PATCH, and DELETE the same as POST', () => {
for (const method of ['PUT', 'PATCH', 'DELETE'] as const) {
const req = makeReq(method, { 'x-idempotency-key': `key-${method}` });