mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -223,6 +223,49 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('oidc_error=token_failed');
|
||||
});
|
||||
|
||||
it('OIDC-010a: missing id_token in token response → redirects with no_id_token error', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=no_id_token');
|
||||
});
|
||||
|
||||
it('OIDC-010b: verifyIdToken failure → redirects with id_token_invalid error', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
|
||||
});
|
||||
|
||||
it('OIDC-010c: userinfo.sub does not match id_token.sub → redirects with subject_mismatch error', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-from-token' } });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-different-from-userinfo',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
|
||||
});
|
||||
|
||||
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
|
||||
// Need at least one existing user so isFirstUser=false
|
||||
createUser(testDb, { email: 'existing@example.com' });
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
Reference in New Issue
Block a user