fix(mcp): security review follow-ups (#24)
Post-merge hardening from the #13 security review: - isInitializeRequestBody now delegates to the SDK isInitializeRequest (same predicate as packages/mcp/http.ts), so a bare {method:'initialize'} with no id/params no longer triggers the side-effecting login() (audit-spam / user_sessions growth) before http.ts 400s it. - Bind the Bearer path to the instance workspace: verifyBearerAccess rejects a token whose payload.workspaceId != the instance workspace (resolved via workspaceRepo.findFirst, consistent with the Basic path); optional param so it's a no-op when unset. - Close the user-enumeration timing oracle in verifyUserCredentials: the missing/disabled branch now runs a bcrypt compare against a module-level dummy hash whose cost (12) matches production saltRounds, so both paths take one equal-cost bcrypt compare; the exact CREDENTIALS_MISMATCH_MESSAGE is preserved. - Document the trusted-proxy requirement for the spoofable per-IP brute-force limiter in .env.example (trustProxy is on; deploy behind a trusted proxy). - Add real-execution coverage for enforceBasicLoginGate (SSO enforced / EE-MFA bundled vs not / user-MFA / workspace-enforced-MFA) instead of stubbing the gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,20 @@ import {
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
// A valid bcrypt hash (cost 10, of an arbitrary throwaway string) used ONLY to
|
||||
// equalize timing in verifyUserCredentials: when the email does not exist or
|
||||
// the user is disabled, we still run ONE bcrypt comparison against this hash
|
||||
// before throwing, so the missing/disabled path takes about the same time as
|
||||
// the real-user wrong-password path. Without it, the "no bcrypt at all" branch
|
||||
// returns measurably faster, leaking whether an email is registered (a user-
|
||||
// enumeration timing oracle, now reachable via /mcp where throttling is only a
|
||||
// spoofable in-memory limiter). This is never used as a real credential.
|
||||
// The cost factor MUST match the production saltRounds (12 — see
|
||||
// common/helpers/utils.ts hashPassword), otherwise the dummy compare runs
|
||||
// faster than a real wrong-password compare and the timing oracle survives.
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$12$q/l637TULK3vU3Cmji0y8utpJS/UiftMi3Jdm4Tsi5EIv/0FE7WV.';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@@ -82,6 +96,12 @@ export class AuthService {
|
||||
// recognises this exact message via isCredentialsFailure.
|
||||
const errorMessage = CREDENTIALS_MISMATCH_MESSAGE;
|
||||
if (!user || isUserDisabled(user)) {
|
||||
// Constant-time intent: run ONE bcrypt comparison (against a dummy hash)
|
||||
// even when the user is missing/disabled, so this path takes about the
|
||||
// same time as the real-user wrong-password path below. This closes the
|
||||
// user-enumeration timing oracle (registered vs. not). The result is
|
||||
// intentionally discarded — we always throw the same credentials error.
|
||||
await comparePasswordHash(loginDto.password, DUMMY_PASSWORD_HASH);
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,4 +100,40 @@ describe('AuthService no-side-effect contract (item 4)', () => {
|
||||
expect(verifyBody.includes(effect)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Item 4: user-enumeration timing-oracle fix. When the email is missing or the
|
||||
// user is disabled, verifyUserCredentials must still run ONE bcrypt comparison
|
||||
// (against a dummy hash) BEFORE throwing, so the missing/disabled path takes
|
||||
// about the same time as the real-user wrong-password path. Asserted at the
|
||||
// source level for the same reason as the rest of this file: AuthService cannot
|
||||
// be imported under this jest config to spy on comparePasswordHash live.
|
||||
describe('constant-time missing/disabled branch (item 4)', () => {
|
||||
// Isolate the body of the `if (!user || isUserDisabled(user)) { ... }` guard.
|
||||
const guardMatch = verifyBody.match(
|
||||
/if \(!user \|\| isUserDisabled\(user\)\) \{([\s\S]*?)\n {4}\}/,
|
||||
);
|
||||
|
||||
it('the missing/disabled guard runs a bcrypt compare before throwing', () => {
|
||||
expect(guardMatch).not.toBeNull();
|
||||
const guardBody = guardMatch![1];
|
||||
// It performs the dummy bcrypt comparison...
|
||||
expect(guardBody).toContain('comparePasswordHash');
|
||||
// ...and only AFTER that throws the credentials error (compare precedes
|
||||
// the throw STATEMENT — match `throw new`, not the word "throw" in a comment).
|
||||
const compareIdx = guardBody.indexOf('comparePasswordHash');
|
||||
const throwIdx = guardBody.indexOf('throw new');
|
||||
expect(compareIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(throwIdx).toBeGreaterThan(compareIdx);
|
||||
});
|
||||
|
||||
it('uses a module-level dummy hash constant (never a real credential)', () => {
|
||||
// The dummy hash is a module-level constant referenced in the guard, not an
|
||||
// inline literal recomputed per call.
|
||||
expect(verifyBody).toContain('DUMMY_PASSWORD_HASH');
|
||||
// Cost factor MUST be 12 to match production saltRounds, otherwise the
|
||||
// dummy compare is faster than a real wrong-password compare and the
|
||||
// timing oracle survives.
|
||||
expect(source).toMatch(/const DUMMY_PASSWORD_HASH =\s*'\$2b\$12\$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user