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:
claude code agent 227
2026-06-20 23:36:53 +03:00
parent b53b0c651e
commit 1f457b060c
7 changed files with 433 additions and 23 deletions

View File

@@ -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);
}

View File

@@ -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\$/);
});
});
});