fix(federation/auth-guard): remediate CRIT-1/CRIT-2 + HIGH-1..4 review findings
- CRIT-1: Validate cert subjectUserId against grant.subjectUserId from DB; use authoritative DB value in FederationContext - CRIT-2: Add @Inject(GrantsService) decorator (tsx/esbuild requirement) - HIGH-1: Validate UTF8String TLV tag, length, and bounds in OID parser - HIGH-2: Collapse all 403 wire messages to a generic string to prevent grant enumeration; keep internal logger detail - HIGH-3: Assert federation wire envelope shape in all guard tests - HIGH-4: Regression test for subjectUserId cert/DB mismatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -162,7 +162,7 @@ describe('FederationAuthGuard', () => {
|
||||
// ── 401: No TLS socket ────────────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => {
|
||||
const { ctx, statusMock } = makeContext({
|
||||
const { ctx, statusMock, sendMock } = makeContext({
|
||||
certPem: certPem,
|
||||
hasTlsSocket: false,
|
||||
});
|
||||
@@ -172,18 +172,28 @@ describe('FederationAuthGuard', () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Cert not presented ───────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when the peer did not present a certificate', async () => {
|
||||
const { ctx, statusMock } = makeContext({ certPem: null });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: null });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Cert parse failure ───────────────────────────────────────────────
|
||||
@@ -191,13 +201,18 @@ describe('FederationAuthGuard', () => {
|
||||
it('returns 401 when the certificate DER bytes are corrupt', async () => {
|
||||
// Build context with a cert that has garbage DER bytes
|
||||
const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----';
|
||||
const { ctx, statusMock } = makeContext({ certPem: corruptPem });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Missing grantId OID ─────────────────────────────────────────────
|
||||
@@ -206,13 +221,18 @@ describe('FederationAuthGuard', () => {
|
||||
// makeSelfSignedCert produces a cert without any Mosaic OIDs
|
||||
const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js');
|
||||
const plainCert = await makeSelfSignedCert();
|
||||
const { ctx, statusMock } = makeContext({ certPem: plainCert });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Missing subjectUserId OID ───────────────────────────────────────
|
||||
@@ -257,13 +277,18 @@ describe('FederationAuthGuard', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const { ctx, statusMock } = makeContext({ certPem: cert.toString('pem') });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant not found ─────────────────────────────────────────────────
|
||||
@@ -275,13 +300,18 @@ describe('FederationAuthGuard', () => {
|
||||
.mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)),
|
||||
});
|
||||
|
||||
const { ctx, statusMock } = makeContext({ certPem });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `pending` status ───────────────────────────────────────
|
||||
@@ -291,13 +321,18 @@ describe('FederationAuthGuard', () => {
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock } = makeContext({ certPem });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `revoked` status ───────────────────────────────────────
|
||||
@@ -309,13 +344,18 @@ describe('FederationAuthGuard', () => {
|
||||
.mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock } = makeContext({ certPem });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `expired` status ───────────────────────────────────────
|
||||
@@ -325,13 +365,18 @@ describe('FederationAuthGuard', () => {
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock } = makeContext({ certPem });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Cert serial mismatch ─────────────────────────────────────────────
|
||||
@@ -360,13 +405,51 @@ describe('FederationAuthGuard', () => {
|
||||
});
|
||||
|
||||
// Context presents cert with serial '01' but DB has 'DEADBEEF'
|
||||
const { ctx, statusMock } = makeContext({ certPem, certSerialHex: '01' });
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────
|
||||
|
||||
it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
|
||||
// Build a cert that claims an attacker's subjectUserId
|
||||
const attackerSubjectUserId = 'attacker-user-id';
|
||||
const attackerCertPem = await makeMosaicIssuedCert({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: attackerSubjectUserId,
|
||||
});
|
||||
|
||||
// DB returns a grant with the legitimate USER_ID
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
|
||||
});
|
||||
|
||||
// Cert presents attacker-user-id but DB has USER_ID — should be rejected
|
||||
const { ctx, statusMock, sendMock } = makeContext({
|
||||
certPem: attackerCertPem,
|
||||
certSerialHex: CERT_SERIAL_HEX,
|
||||
});
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user