fix(mcp): close SSO/MFA bypass on Basic + stop non-init session mint

Release-cycle review found the /mcp Basic path skipped the controller's
pre-token gates and over-eagerly minted sessions:

- SSO/MFA bypass (blocker): the Basic path called AuthService.login/
  verifyUserCredentials directly, but validateSsoEnforcement + the lazy EE MFA
  gate live in AuthController.login. Now enforceBasicLoginGate runs in the Basic
  branch BEFORE any token is minted: validateSsoEnforcement(workspace) (reject
  on enforced SSO) and the same lazy-require MFA check the controller uses
  (reject MFA users -> 'use a Bearer access token'). No EE module bundled (this
  fork) -> no MFA gate, identical to the controller; a throw from the check
  fails closed (no token). Bearer/service-account paths are not gated (those
  JWTs are minted post-gate).
- Non-init session mint: isSessionInit is now (no mcp-session-id) AND the body
  is a real JSON-RPC initialize (isInitializeRequestBody). A header-less
  non-initialize request takes the side-effect-free verifyCredentials path -> no
  user_sessions row, no USER_LOGIN audit, no lastLoginAt bump.
- FailedLoginLimiter.sweep() now runs on an unref'd 60s interval, cleared on
  module destroy (was never scheduled -> unbounded Map growth under XFF rotation).
- Subsequent (non-init) valid login no longer resets the global per-email brute
  bucket (only per-IP / per-IP+email); the email backstop is reset only on a
  deliberate init login.

Note: in a hypothetical EE build, checkMfaRequirements is called with no
FastifyReply (we only read requirement flags); a res-dereferencing EE impl would
surface as a clean rejection (fail-closed), not a bypass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 13:27:17 +03:00
parent 1483e021d1
commit bfd79b94bc
3 changed files with 345 additions and 8 deletions

View File

@@ -116,6 +116,20 @@ export interface McpAuthDeps {
email?: string;
password?: string;
findWorkspace: () => Promise<{ id: string } | undefined>;
// Pre-token gate for the Basic path ONLY, replicating what AuthController.login
// does BEFORE issuing a token: validateSsoEnforcement(workspace) and the lazy
// EE MFA requirement check. It is invoked with the resolved (default)
// workspace right after it is loaded and BEFORE any login()/verifyCredentials()
// call, so an SSO-enforced workspace or an MFA-required user never gets a token
// via /mcp Basic. It MUST throw (UnauthorizedException) to reject; on a fork
// without the EE MFA module bundled it behaves exactly like the controller
// (no MFA module -> no MFA gate). The Bearer path skips this gate because those
// ACCESS JWTs were already minted post-gate by the normal controller login.
// Optional so existing callers/tests that don't exercise the gate are unchanged.
enforceBasicGate?: (
workspace: { id: string },
creds: { email: string; password: string },
) => Promise<void> | void;
// Full login: mints a user session + JWT, writes the USER_LOGIN audit event
// and updates lastLoginAt. Called at MOST once per MCP session (at the
// session-init request) so we do not spam the audit log / user_sessions table
@@ -227,6 +241,25 @@ export async function verifyBearerAccess(
return { sub: payload.sub, email: payload.email };
}
/**
* Detect a genuine JSON-RPC `initialize` request from an already-parsed body.
* Mirrors the @modelcontextprotocol/sdk `isInitializeRequest` signal that
* packages/mcp/src/http.ts uses to decide whether to mint a session, but
* framework/SDK-free so it is unit-testable and usable from the CommonJS
* McpService. An initialize request is a single JSON-RPC object whose `method`
* is exactly 'initialize'; a batch (array) body is never an initialize request.
*
* This is the second half of the session-INIT decision: `isSessionInit` is
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Using it
* ensures the side-effecting login() (user_sessions insert + USER_LOGIN audit +
* lastLoginAt) only runs for a real initialize, never for an arbitrary
* header-less request that http.ts will subsequently 400.
*/
export function isInitializeRequestBody(body: unknown): boolean {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
return (body as { method?: unknown }).method === 'initialize';
}
/** Extract a Bearer token from an Authorization header (case-insensitive). */
export function extractBearer(
authHeader: string | undefined,
@@ -283,6 +316,22 @@ export async function resolveMcpSessionConfig(
throw new UnauthorizedException('No workspace is configured.');
}
// SSO/MFA pre-token gate (BLOCKER fix): replicate the AuthController.login
// gates BEFORE any token is issued on the Basic path. If the workspace
// enforces SSO, or the EE MFA module is bundled and this user/workspace
// requires MFA, this throws and we never mint a token. The Bearer path is
// intentionally NOT gated here (its JWT was already minted post-gate). This
// runs on BOTH init and subsequent Basic requests, but it must run before
// login()/verifyCredentials so an SSO/MFA user cannot authenticate at all.
// We do NOT count a gate rejection toward the brute-force limiter: it is not
// a password-guess signal.
if (deps.enforceBasicGate) {
await deps.enforceBasicGate(workspace, {
email: basic.email,
password: basic.password,
});
}
// Fix 1 (init vs subsequent):
// - SESSION INIT (no mcp-session-id): full login() mints the user JWT
// (the one allowed session creation + audit event for this MCP
@@ -330,10 +379,16 @@ export async function resolveMcpSessionConfig(
: 'Email or password does not match';
throw new UnauthorizedException(message);
}
// Subsequent request, credentials valid: clear any prior failure budget.
// Subsequent request, credentials valid: clear the per-IP and per-IP+email
// budget, but DELIBERATELY do NOT reset the GLOBAL per-email key here. That
// email key is the only brute-force backstop that survives IP/XFF rotation;
// resetting it on every periodic tool call of a victim's live MCP session
// would repeatedly wipe a parallel attacker's failed-login budget for that
// email. The global email key is reset ONLY on a session-INIT login()
// success (above), which is a single deliberate authentication, not a
// high-frequency re-validation.
deps.limiter.reset(ipKey);
deps.limiter.reset(ipEmailKey);
deps.limiter.reset(emailKey);
return {
config: { apiUrl, getToken: async () => '' },
identity: `basic:${emailLc}`,

View File

@@ -4,6 +4,7 @@ import {
FailedLoginLimiter,
resolveMcpSessionConfig,
isCredentialsFailure,
isInitializeRequestBody,
verifyBearerAccess,
McpAuthDeps,
} from './mcp-auth.helpers';
@@ -31,6 +32,10 @@ function makeDeps(over: Partial<McpAuthDeps> = {}): McpAuthDeps {
verifyAccessJwt:
over.verifyAccessJwt ??
jest.fn().mockResolvedValue({ sub: 'user-1', email: 'u@e.com' }),
// Default gate is a no-op (pass-through), matching a build with no SSO
// enforcement and no EE MFA module. Individual tests override it to assert
// the SSO/MFA reject behaviour.
enforceBasicGate: over.enforceBasicGate,
limiter: over.limiter ?? new FailedLoginLimiter(5, 60_000),
clientIp: over.clientIp ?? '10.0.0.1',
// Default to the session-INIT request (no mcp-session-id) so existing
@@ -393,4 +398,183 @@ describe('resolveMcpSessionConfig', () => {
expect(b.identity).toBe('basic:bob@example.com');
expect(a.identity).not.toBe(b.identity);
});
// --- BLOCKER: SSO/MFA pre-token gate on the Basic path ---
it('Basic rejected (no token) when the SSO/MFA gate throws (SSO enforced)', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
// The service wires enforceBasicGate to validateSsoEnforcement + the lazy
// MFA check. Here we stub it to throw as it would for an SSO-enforced
// workspace; the gate runs BEFORE login()/verifyCredentials, so no token.
const enforceBasicGate = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('This workspace has enforced SSO login.'),
);
await expect(
resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, enforceBasicGate }),
),
).rejects.toThrow(/enforced SSO/);
expect(enforceBasicGate).toHaveBeenCalledWith(
{ id: 'ws-1' },
{ email: 'user@example.com', password: 'pw' },
);
// The pre-token gate fired first: no token-minting login() and no
// verifyCredentials() happened.
expect(login).not.toHaveBeenCalled();
expect(verifyCredentials).not.toHaveBeenCalled();
});
it('Basic rejected with a "use a Bearer token" message when MFA is required', async () => {
const login = jest.fn().mockResolvedValue('issued-user-jwt');
// Mirror McpService.enforceBasicLoginGate when the EE MFA module is present
// and the user has MFA: it throws telling the caller to use a Bearer token.
const enforceBasicGate = jest
.fn()
.mockRejectedValue(
new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
),
);
await expect(
resolveMcpSessionConfig(
basicHeader('mfa-user@example.com', 'pw'),
makeDeps({ login, enforceBasicGate }),
),
).rejects.toThrow(/use a Bearer access token/);
expect(login).not.toHaveBeenCalled();
});
it('Bearer path is NOT subjected to the Basic SSO/MFA gate', async () => {
// The gate is only consulted on the Basic branch. A Bearer token (minted
// post-gate by the normal login) must not be blocked by it.
const enforceBasicGate = jest.fn();
const resolved = await resolveMcpSessionConfig(
'Bearer some.jwt.value',
makeDeps({ enforceBasicGate }),
);
expect(enforceBasicGate).not.toHaveBeenCalled();
expect('getToken' in resolved.config).toBe(true);
});
it('a session-INIT login() success DOES reset the global per-email key', async () => {
const limiter = new FailedLoginLimiter(5, 60_000);
// Pre-load some failure budget on the global email key.
const emailKey = 'email:victim@example.com';
limiter.recordFailure(emailKey);
limiter.recordFailure(emailKey);
await resolveMcpSessionConfig(
basicHeader('victim@example.com', 'pw'),
makeDeps({ limiter, isSessionInit: true }),
);
// After a real init login, the deliberate authentication clears the email
// bucket entirely.
expect(limiter.isBlocked(emailKey)).toBe(false);
limiter.recordFailure(emailKey);
// Only one failure now (bucket was reset), so still far from threshold 5.
expect(limiter.isBlocked(emailKey)).toBe(false);
});
it('a SUBSEQUENT valid login does NOT reset the global per-email bucket (only per-IP keys)', async () => {
const limiter = new FailedLoginLimiter(2, 60_000);
const clientIp = '10.0.0.5';
const emailLc = 'victim@example.com';
const emailKey = `email:${emailLc}`;
const ipKey = `ip:${clientIp}`;
const ipEmailKey = `ip-email:${clientIp}:${emailLc}`;
// An attacker (different IP rotation) has driven the global email key to the
// threshold; also seed the per-IP keys for the victim's own IP.
limiter.recordFailure(emailKey);
limiter.recordFailure(emailKey);
limiter.recordFailure(ipKey);
limiter.recordFailure(ipEmailKey);
// The victim's live session would be throttled too (shared email key), so to
// exercise the SUBSEQUENT success path we use a SEPARATE limiter assertion:
// verify the reset behaviour directly on the keys the helper touches. Build a
// limiter where only the per-IP budget is set so the request is not blocked.
const lim2 = new FailedLoginLimiter(2, 60_000);
lim2.recordFailure(emailKey); // 1 failure on the global email key
lim2.recordFailure(ipKey);
lim2.recordFailure(ipEmailKey);
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
await resolveMcpSessionConfig(
basicHeader(emailLc, 'pw'),
makeDeps({ limiter: lim2, clientIp, verifyCredentials, isSessionInit: false }),
);
expect(verifyCredentials).toHaveBeenCalled();
// Per-IP keys were cleared by the subsequent success...
expect(lim2.isBlocked(ipKey)).toBe(false);
// ...but the global per-email key was DELIBERATELY left intact (still 1).
lim2.recordFailure(emailKey); // -> 2 == threshold
expect(lim2.isBlocked(emailKey)).toBe(true);
});
});
describe('isInitializeRequestBody (session-INIT detection)', () => {
it('true only for a single JSON-RPC object with method === "initialize"', () => {
expect(isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' })).toBe(
true,
);
});
it('false for a non-initialize method (e.g. tools/call)', () => {
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'tools/call' }),
).toBe(false);
});
it('false for a batch (array) body, null/undefined, or a non-object', () => {
expect(
isInitializeRequestBody([{ jsonrpc: '2.0', method: 'initialize' }]),
).toBe(false);
expect(isInitializeRequestBody(undefined)).toBe(false);
expect(isInitializeRequestBody(null)).toBe(false);
expect(isInitializeRequestBody('initialize')).toBe(false);
});
});
describe('isSessionInit decision (no mcp-session-id AND initialize body)', () => {
// The service computes isSessionInit = !mcp-session-id && isInitializeRequestBody(body).
// This proves a header-less but NON-initialize request is NOT treated as init,
// so it goes down the non-side-effecting verifyCredentials path (no orphan
// session/audit before http.ts 400s it).
const decide = (sessionId: string | undefined, body: unknown): boolean =>
!sessionId && isInitializeRequestBody(body);
it('no header + initialize body -> init', () => {
expect(decide(undefined, { method: 'initialize' })).toBe(true);
});
it('no header + non-initialize body -> NOT init (verifyCredentials path)', () => {
expect(decide(undefined, { method: 'tools/list' })).toBe(false);
});
it('has session-id -> never init regardless of body', () => {
expect(decide('sess-1', { method: 'initialize' })).toBe(false);
});
});
describe('resolveMcpSessionConfig non-initialize request side effects', () => {
it('header-less NON-initialize request does NOT call session-minting login() (uses verifyCredentials)', async () => {
// Simulate the service decision: no mcp-session-id but body is NOT initialize
// -> isSessionInit false -> the helper must use verifyCredentials, not login.
const login = jest.fn().mockResolvedValue('issued-user-jwt');
const verifyCredentials = jest.fn().mockResolvedValue(undefined);
const isSessionInit = isInitializeRequestBody({ method: 'tools/call' }); // false
await resolveMcpSessionConfig(
basicHeader('user@example.com', 'pw'),
makeDeps({ login, verifyCredentials, isSessionInit }),
);
expect(login).not.toHaveBeenCalled();
expect(verifyCredentials).toHaveBeenCalledWith(
{ email: 'user@example.com', password: 'pw' },
'ws-1',
);
});
});

View File

@@ -1,8 +1,10 @@
import {
Injectable,
Logger,
OnModuleDestroy,
UnauthorizedException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { pathToFileURL } from 'node:url';
import { timingSafeEqual } from 'node:crypto';
import { IncomingMessage } from 'node:http';
@@ -13,11 +15,14 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { AuthService } from '../../core/auth/services/auth.service';
import { TokenService } from '../../core/auth/services/token.service';
import { validateSsoEnforcement } from '../../core/auth/auth.util';
import { JwtType, JwtPayload } from '../../core/auth/dto/jwt-payload';
import { Workspace } from '@docmost/db/types/entity.types';
import {
FailedLoginLimiter,
resolveMcpSessionConfig,
verifyBearerAccess,
isInitializeRequestBody,
DocmostMcpConfig,
ResolvedMcpAuth,
} from './mcp-auth.helpers';
@@ -58,7 +63,7 @@ const esmImport = new Function(
) as (specifier: string) => Promise<unknown>;
@Injectable()
export class McpService {
export class McpService implements OnModuleDestroy {
private readonly logger = new Logger(McpService.name);
private handler: McpHttpHandler | null = null;
private handlerPromise: Promise<McpHttpHandler> | null = null;
@@ -69,6 +74,13 @@ export class McpService {
// this is the brute-force speed bump for /mcp. 5 failures per 60s window.
private readonly failedLogins = new FailedLoginLimiter(5, 60_000);
// Periodically drop expired limiter buckets so never-revisited keys do not
// accumulate forever (unbounded memory growth / DoS via forgeable XFF keys).
// unref()'d so it never keeps the process alive; cleared on module destroy.
// Mirrors the sweepTimer pattern in packages/mcp/src/http.ts.
private readonly sweepIntervalMs = 60_000;
private readonly sweepTimer: NodeJS.Timeout;
constructor(
private readonly environmentService: EnvironmentService,
private readonly workspaceRepo: WorkspaceRepo,
@@ -76,7 +88,22 @@ export class McpService {
private readonly tokenService: TokenService,
private readonly userRepo: UserRepo,
private readonly userSessionRepo: UserSessionRepo,
) {}
private readonly moduleRef: ModuleRef,
) {
this.sweepTimer = setInterval(() => {
try {
this.failedLogins.sweep();
} catch (err) {
this.logger.error('MCP failed-login limiter sweep failed', err as Error);
}
}, this.sweepIntervalMs);
// Do not let this interval hold the event loop open.
this.sweepTimer.unref?.();
}
onModuleDestroy(): void {
clearInterval(this.sweepTimer);
}
// Service account the embedded MCP uses to talk back to this Docmost
// instance over loopback REST + the collaboration WebSocket. Now OPTIONAL:
@@ -188,15 +215,24 @@ export class McpService {
const authHeader = req.headers['authorization'] as string | undefined;
// A request carrying an mcp-session-id is operating on an ALREADY
// established session (see packages/mcp/src/http.ts: a new session is only
// minted by an initialize POST with no session id). Only the INIT request
// should run the full, session-minting login(); subsequent requests only
// re-validate credentials (anti-fixation) with no side effects.
const isSessionInit = !req.headers['mcp-session-id'];
// minted by an initialize POST with no session id). The session-minting
// login() (user_sessions insert + USER_LOGIN audit + lastLoginAt bump) must
// run ONLY for a genuine session INITIALIZE: no mcp-session-id AND the
// JSON-RPC body is an `initialize` request — the same signal http.ts uses to
// decide whether to mint a session. Any other request (e.g. a non-initialize
// body with no session id, which http.ts will 400) uses the non-side-
// effecting verifyCredentials path so it never mints an orphan DB
// session/audit row before being rejected.
const isSessionInit =
!req.headers['mcp-session-id'] &&
isInitializeRequestBody((req as unknown as { body?: unknown }).body);
return resolveMcpSessionConfig(authHeader, {
apiUrl: this.getApiUrl(),
email: this.getEmail(),
password: this.getPassword(),
findWorkspace: () => this.workspaceRepo.findFirst(),
enforceBasicGate: (workspace, creds) =>
this.enforceBasicLoginGate(workspace as Workspace, creds),
login: (creds, workspaceId) => this.authService.login(creds, workspaceId),
verifyCredentials: async (creds, workspaceId) => {
await this.authService.verifyUserCredentials(creds, workspaceId);
@@ -208,6 +244,68 @@ export class McpService {
});
}
// Pre-token gate for the /mcp HTTP-Basic path, replicating EXACTLY what
// AuthController.login does before issuing a token, so the Basic path is not
// an SSO/MFA bypass:
// 1) validateSsoEnforcement(workspace) — reject if the workspace enforces
// SSO (a password login is not allowed there).
// 2) Lazily require the EE MFA module (same pattern/path as the controller).
// If it is bundled and the user has MFA enabled OR the workspace enforces
// MFA, reject the Basic path and tell the caller to use a Bearer token (a
// Bearer ACCESS JWT is only minted AFTER the normal gated login, so it is
// safe). A fork WITHOUT the EE module behaves exactly like the controller:
// no MFA module -> no MFA gate.
// Throws UnauthorizedException on rejection (surfaced as a clean 401, never a
// torn/hijacked response, never a token). Never logs the password.
private async enforceBasicLoginGate(
workspace: Workspace,
creds: { email: string; password: string },
): Promise<void> {
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
// token is never issued.
try {
validateSsoEnforcement(workspace);
} catch {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
}
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
return;
}
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Use the same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
// which we never take here): we only read the requirement flags. Be tolerant
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
const mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
}
// Lazily create the HTTP handler exactly once. The import is indirected so
// the ESM-only @docmost/mcp package can be loaded from this CommonJS module.
// The handler is created with a per-request RESOLVER (and an `identify` hook