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

@@ -0,0 +1,253 @@
import { UnauthorizedException } from '@nestjs/common';
// ---------------------------------------------------------------------------
// These tests exercise the REAL McpService.enforceBasicLoginGate (the pre-token
// SSO/MFA gate on the /mcp HTTP-Basic path). Unlike the resolveMcpSessionConfig
// tests in mcp.service.spec.ts — which STUB the gate and only assert it runs
// before login()/verifyCredentials — here the gate logic is instantiated for
// real and only its LEAF dependencies are mocked:
// - the workspace object (plain object with/without enforceSso),
// - the user credentials (plain object),
// - the lazily-required EE MFA module (jest.mock with { virtual: true } so we
// can simulate BOTH "bundled" and "not bundled" community-build states),
// - the injected MfaService instance (via a stub moduleRef).
//
// McpService cannot normally be imported under jest because it imports
// AuthService, which drags in the React email-template graph
// (@docmost/transactional/emails/*) that the jest moduleNameMapper does not
// resolve. We therefore mock the heavy collaborator modules (auth.service,
// token.service, the @docmost/db repos and mcp-auth.helpers) at the module
// level so importing mcp.service.ts succeeds. None of those are touched by the
// gate itself, so the gate runs unmodified against the real code path.
// ---------------------------------------------------------------------------
// The EE MFA module specifier the jest.mock below intercepts MUST be
// byte-for-byte the specifier that mcp.service.ts lazily require()s
// ('./../../ee/mfa/services/mfa.service'). jest.mock is hoisted above all
// non-hoisted code, so the path is inlined as a literal in the call below
// rather than referenced through a const (which would not yet be initialised).
// `{ virtual: true }` is required because the EE module does not exist in this
// OSS build (there is no src/ee directory) — without it jest cannot register a
// mock for a path it cannot resolve on disk.
// Mutable handle the virtual mock factory reads, so each test can decide whether
// the EE module is "bundled" (factory returns a MfaService class) or "not
// bundled" (factory throws, mimicking the require() failing on a community
// build). jest.mock is hoisted, so the factory must close over this lazily.
let mfaModuleState: { bundled: boolean; checkMfaRequirements?: jest.Mock } = {
bundled: false,
};
jest.mock(
'./../../ee/mfa/services/mfa.service',
() => {
if (!mfaModuleState.bundled) {
// Simulate a community/fork build with no EE MFA module: the real
// require() throws, which the gate catches as the "no MFA gate" path.
throw new Error('Cannot find module (EE MFA not bundled)');
}
// "Bundled" build: expose a MfaService class token. The actual instance the
// gate calls is resolved through moduleRef.get(MfaModule.MfaService), which
// our stub moduleRef returns regardless of the token identity.
class MfaService {}
return { MfaService };
},
{ virtual: true },
);
// --- Mock the heavy collaborator modules so importing mcp.service succeeds. ---
// The gate never calls into these; they exist only to satisfy the import graph.
jest.mock('../../core/auth/services/auth.service', () => ({
AuthService: class AuthService {},
}));
jest.mock('../../core/auth/services/token.service', () => ({
TokenService: class TokenService {},
}));
jest.mock('@docmost/db/repos/workspace/workspace.repo', () => ({
WorkspaceRepo: class WorkspaceRepo {},
}));
jest.mock('@docmost/db/repos/user/user.repo', () => ({
UserRepo: class UserRepo {},
}));
jest.mock('@docmost/db/repos/session/user-session.repo', () => ({
UserSessionRepo: class UserSessionRepo {},
}));
// mcp-auth.helpers exports both runtime values (FailedLoginLimiter is used in
// the constructor) and types. Provide a minimal FailedLoginLimiter so the
// constructor runs; everything else the gate path doesn't need.
jest.mock('./mcp-auth.helpers', () => ({
FailedLoginLimiter: class FailedLoginLimiter {
sweep() {}
},
}));
// Import AFTER the mocks are registered.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import { McpService } from './mcp.service';
type GateCreds = { email: string; password: string };
// Build an McpService instance with stubbed constructor deps. We never call the
// auth/db collaborators from the gate, so undefined stand-ins are fine for all
// but moduleRef, which the MFA branch reads.
function makeService(opts: {
checkMfaRequirements?: jest.Mock;
}): { service: McpService; gate: (ws: unknown, creds: GateCreds) => Promise<void> } {
// Stub moduleRef.get -> returns an object whose checkMfaRequirements is the
// provided mock. The gate calls moduleRef.get(MfaModule.MfaService).
const moduleRef = {
get: jest.fn().mockReturnValue({
checkMfaRequirements:
opts.checkMfaRequirements ?? jest.fn().mockResolvedValue(undefined),
}),
};
const service = new McpService(
undefined as never, // environmentService
undefined as never, // workspaceRepo
undefined as never, // authService
undefined as never, // tokenService
undefined as never, // userRepo
undefined as never, // userSessionRepo
moduleRef as never, // moduleRef (read by the MFA branch)
);
// Stop the constructor's unref'd sweep timer leaking across tests.
service.onModuleDestroy();
// enforceBasicLoginGate is private; reach it through the instance. Calling the
// REAL method (not a stub) is the whole point of this suite.
const gate = (
service as unknown as {
enforceBasicLoginGate: (ws: unknown, creds: GateCreds) => Promise<void>;
}
).enforceBasicLoginGate.bind(service);
return { service, gate };
}
const CREDS: GateCreds = { email: 'user@example.com', password: 'pw' };
describe('McpService.enforceBasicLoginGate (REAL gate, leaf deps mocked)', () => {
beforeEach(() => {
// Reset to the community-build default (no EE module) before each test.
mfaModuleState = { bundled: false };
jest.clearAllMocks();
});
describe('SSO enforcement (validateSsoEnforcement)', () => {
it('rejects with Unauthorized when the workspace enforces SSO, before any MFA/login', async () => {
const { gate } = makeService({});
const workspace = { id: 'ws-1', enforceSso: true };
await expect(gate(workspace, CREDS)).rejects.toBeInstanceOf(
UnauthorizedException,
);
// The /mcp 401 surfaces an SSO-specific message (not a generic MCP error).
await expect(gate(workspace, CREDS)).rejects.toThrow(/enforced SSO/i);
});
it('does NOT consult the MFA module when SSO is enforced (gate short-circuits)', async () => {
// Even if the EE module WERE bundled, the SSO branch throws first, so the
// moduleRef MFA lookup must never run.
mfaModuleState = {
bundled: true,
checkMfaRequirements: jest.fn(),
};
const { service, gate } = makeService({
checkMfaRequirements: mfaModuleState.checkMfaRequirements,
});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: true }, CREDS),
).rejects.toThrow(/enforced SSO/i);
// The SSO branch fired before the MFA require/lookup.
expect(moduleRefGet).not.toHaveBeenCalled();
expect(mfaModuleState.checkMfaRequirements).not.toHaveBeenCalled();
});
});
describe('community build: EE MFA module NOT bundled', () => {
it('passes (no throw) when SSO is not enforced and the lazy require fails (no MFA gate)', async () => {
// mfaModuleState.bundled === false -> the virtual mock factory throws,
// exactly like require() of a missing EE module on a community build.
const { service, gate } = makeService({});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The require() failed, so the gate returned before touching moduleRef.
expect(moduleRefGet).not.toHaveBeenCalled();
});
});
describe('EE MFA module bundled', () => {
it('rejects with a "use a Bearer token" signal when the user has MFA enabled', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: true,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
const promise = gate({ id: 'ws-1', enforceSso: false }, CREDS);
await expect(promise).rejects.toBeInstanceOf(UnauthorizedException);
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
// The real requirement check was consulted with the creds + workspace.
expect(check).toHaveBeenCalledWith(
CREDS,
{ id: 'ws-1', enforceSso: false },
undefined,
);
});
it('rejects when the workspace enforces MFA (requiresMfaSetup)', async () => {
// requiresMfaSetup === true models a workspace that enforces MFA for a
// user who has not set it up yet; the Basic path cannot complete it.
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: true,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
});
it('passes when the user has no MFA and the workspace does not enforce it', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The bundled module's requirement check WAS consulted (proving we took
// the bundled branch, not the community no-op branch).
expect(check).toHaveBeenCalledTimes(1);
});
it('passes when checkMfaRequirements returns a falsy result (no requirement flags)', async () => {
// Defensive: a bundled module that returns undefined must not reject.
const check = jest.fn().mockResolvedValue(undefined);
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
});
});
});