Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/e2e/demo-e2e-security/e2e/loopback-origin.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Proposal 1.7: a loopback server refuses a request from another site's page. The browser sends that
* page's `Origin`; the `Host` is a loopback name, so the Host check alone let it through. A request
* with no `Origin` (CLI and agent clients) and one from the server's own origin are still served.
*/
import * as path from 'node:path';

import { expect, TestServer } from '@frontmcp/testing';

// Run from the app directory, whose tsconfig resolves @frontmcp/sdk to its source rather than a build.
const SERVER = { command: 'npx tsx src/loopback-origin.main.ts', cwd: path.join(__dirname, '..') };

const INITIALIZE = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'origin-e2e', version: '1.0' } },
});

function initialize(server: TestServer, origin?: string): Promise<Response> {
return fetch(`${server.info.baseUrl}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(origin ? { Origin: origin } : {}),
},
body: INITIALIZE,
});
}

describe('Origin check on a loopback bind', () => {
let derived: TestServer;
let explicit: TestServer;

beforeAll(async () => {
derived = await TestServer.start({ ...SERVER, project: 'demo-e2e-security', startupTimeout: 30_000 });
explicit = await TestServer.start({
...SERVER,
project: 'demo-e2e-security',
env: { ALLOWED_ORIGINS: 'https://app.example.com' },
startupTimeout: 30_000,
});
}, 90_000);

afterAll(async () => {
await derived?.stop();
await explicit?.stop();
});

it('refuses a cross-origin POST before it opens a session', async () => {
const res = await initialize(derived, 'https://evil.example');

expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: 'Forbidden', message: 'Invalid Origin header' });
expect(res.headers.get('mcp-session-id')).toBeNull();
});

it('serves a request with no Origin', async () => {
const res = await initialize(derived);

expect(res.status).toBe(200);
expect(res.headers.get('mcp-session-id')).toBeTruthy();
});

it('serves a request from its own origin', async () => {
const res = await initialize(derived, `http://localhost:${derived.info.port}`);

expect(res.status).toBe(200);
});

it('lets an explicit allowedOrigins decide instead', async () => {
expect((await initialize(explicit, 'https://app.example.com')).status).toBe(200);
expect((await initialize(explicit, `http://localhost:${explicit.info.port}`)).status).toBe(403);
expect((await initialize(explicit)).status).toBe(200);
});
});
27 changes: 27 additions & 0 deletions apps/e2e/demo-e2e-security/src/loopback-origin.main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* A public loopback server for the Origin check. `ALLOWED_ORIGINS` (comma-separated) sets an explicit
* `dnsRebindingProtection.allowedOrigins`; without it the allowed Origins are derived from the listener.
*/
import { App, FrontMcp, LogLevel, Tool, ToolContext } from '@frontmcp/sdk';

@Tool({ name: 'ping', inputSchema: {} })
class PingTool extends ToolContext {
async execute() {
return { pong: true };
}
}

@App({ id: 'origin', name: 'Origin', tools: [PingTool] })
class OriginApp {}

const port = Number.parseInt(process.env['PORT'] ?? '3151', 10);
const allowedOrigins = (process.env['ALLOWED_ORIGINS'] ?? '').split(',').filter(Boolean);

@FrontMcp({
info: { name: 'Demo E2E Loopback Origin', version: '0.1.0' },
apps: [OriginApp],
logging: { level: LogLevel.Warn },
http: allowedOrigins.length ? { port, security: { dnsRebindingProtection: { allowedOrigins } } } : { port },
auth: { mode: 'public' },
})
export default class Server {}
24 changes: 14 additions & 10 deletions apps/e2e/demo-e2e-standalone/e2e/cors-default.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* BREAKING in v1.7.0 (BC-034): omitting `http.cors` used to install permissive
* CORS (`origin: true`); it now installs no CORS middleware at all.
*
* The docs make a claim that is easy to get backwards, so it is pinned here: a
* cross-origin request is still DELIVERED and served normally — the server does
* not reject it — and the response simply carries no `Access-Control-Allow-Origin`
* header, which is what makes a browser refuse to let the calling page read it.
* CORS is a browser rule, not server-side access control.
* CORS is a browser rule, not server-side access control: the response carries no
* `Access-Control-Allow-Origin` header, which is what makes a browser refuse to
* let the calling page read it. Refusing the request is a separate check: this
* server binds loopback, where a request from another origin is refused outright
* (proposal 1.7) — a request with no `Origin` is still served.
*
* The browser half of that (refusing the read) is enforced by the browser vendor
* from the absence of the header, so driving a real browser here would test
Expand All @@ -26,18 +26,22 @@ test.describe('CORS default: no headers', () => {
publicMode: true,
});

test('delivers and serves a cross-origin request, but sends no CORS headers', async ({ server }) => {
test('refuses a cross-origin request on a loopback bind, and sends no CORS headers', async ({ server }) => {
const res = await fetch(`${server.info.baseUrl}/custom/ping`, {
headers: { Origin: CROSS_ORIGIN },
});

// Delivered and served — the server does not reject cross-origin callers.
expect(res.status).toBe(403);
expect(res.headers.get('access-control-allow-origin')).toBeNull();
expect(res.headers.get('access-control-allow-credentials')).toBeNull();
});

test('serves a request with no Origin and sends no CORS headers', async ({ server }) => {
const res = await fetch(`${server.info.baseUrl}/custom/ping`);

expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ok: true });

// ...but nothing tells the browser the calling page may read it.
expect(res.headers.get('access-control-allow-origin')).toBeNull();
expect(res.headers.get('access-control-allow-credentials')).toBeNull();
});

test('does not answer a preflight with CORS headers', async ({ server }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* file, so a CORS-configured server and a CORS-less one cannot share one.
*
* The contrast is the point: a configured origin gets an
* `Access-Control-Allow-Origin` header, an unconfigured one does not — and
* neither is REJECTED. CORS decides what a browser may read, never what the
* server will answer.
* `Access-Control-Allow-Origin` header and is served. An unconfigured one is
* refused, because this server binds loopback and checks `Origin` there
* (proposal 1.7); the CORS list is what admits the configured one.
*/
import { expect, test } from '@frontmcp/testing';

Expand All @@ -27,13 +27,12 @@ test.describe('CORS opt-in: explicit origin', () => {
expect(res.headers.get('access-control-allow-origin')).toBe('https://allowed.example.com');
});

test('never echoes an origin that is not on the list', async ({ server }) => {
test('refuses an origin that is not on the list, and never echoes it', async ({ server }) => {
const res = await fetch(`${server.info.baseUrl}/custom/ping`, {
headers: { Origin: 'https://evil.example.com' },
});

// Still delivered — CORS never blocks the request, only the browser-side read.
expect(res.status).toBe(200);
expect(res.status).toBe(403);
// A fixed `origin` string is sent on every response, so the header IS present; what matters is
// that it names the allowed origin, never the caller's. The browser compares the two and
// refuses the read on a mismatch.
Expand Down
8 changes: 8 additions & 0 deletions apps/e2e/demo-e2e-standalone/e2e/mcp-registry.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ describe('MCP Registry documents', () => {
expect((await fetch(`${server.info.baseUrl}/mcp/isolated/server-card`)).status).toBe(404);
});

it('refuses the card to a page on another origin, since the server binds loopback', async () => {
const card = await fetch(`${server.info.baseUrl}/mcp/server-card`, {
headers: { Origin: 'https://registry.example' },
});

expect(card.status).toBe(403);
});

it('lists the card in the AI Catalog', async () => {
const catalog = await (await fetch(`${server.info.baseUrl}/.well-known/ai-catalog.json`)).json();
expect(catalog).toEqual({
Expand Down
21 changes: 17 additions & 4 deletions apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ export class StdioJsonRpcProcess {
});
}

static start(entrypointPath: string, cwd: string): StdioJsonRpcProcess {
const child = spawn('npx', ['tsx', entrypointPath], {
/** Run the entrypoint in this process's node, so a signal reaches the server itself and not a launcher shell. */
static start(entrypointPath: string, cwd: string, env: Record<string, string> = {}): StdioJsonRpcProcess {
const child = spawn(process.execPath, ['--import', 'tsx', entrypointPath], {
cwd,
env: process.env as Record<string, string>,
env: { ...process.env, ...env } as Record<string, string>,
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stderr.resume();
Expand Down Expand Up @@ -66,8 +67,20 @@ export class StdioJsonRpcProcess {
}
}

private hasExited(): boolean {
return this.child.exitCode !== null || this.child.signalCode !== null;
}

/** Send SIGTERM, leaving stdin open, and resolve with the exit code. */
async terminate(): Promise<number | null> {
if (this.hasExited()) return this.child.exitCode;
const exited = new Promise<number | null>((resolve) => this.child.once('exit', (code) => resolve(code)));
this.child.kill('SIGTERM');
return exited;
}

async stop(): Promise<void> {
if (this.child.exitCode !== null) return;
if (this.hasExited()) return;
const exited = new Promise((resolve) => this.child.once('close', resolve));
this.child.stdin.end();
this.child.kill('SIGTERM');
Expand Down
43 changes: 43 additions & 0 deletions apps/e2e/demo-e2e-stdio-transport/e2e/stdio-dispose.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* E2E: SIGTERM on a stdio server disposes its scope, so `onDispose` callbacks run before it exits.
* They never ran under stdio: the handler shut the scope down and exited without disposing it.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import { StdioJsonRpcProcess } from './helpers/stdio-json-rpc';

const projectPath = path.join(__dirname, '..');

describe('stdio shutdown', () => {
let directory: string;
let server: StdioJsonRpcProcess | undefined;

beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stdio-dispose-'));
});

afterEach(async () => {
await server?.stop();
server = undefined;
fs.rmSync(directory, { recursive: true, force: true });
});

it('runs onDispose callbacks on SIGTERM, then exits 0', async () => {
const marker = path.join(directory, 'disposed');
server = StdioJsonRpcProcess.start('src/stdio-dispose-entrypoint.ts', projectPath, { DISPOSE_MARKER: marker });
const initialized = await server.request({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dispose-e2e', version: '1.0' } },
});
expect(initialized.result).toBeDefined();

const exitCode = await server.terminate();

expect(exitCode).toBe(0);
expect(fs.readFileSync(marker, 'utf8')).toBe('disposed');
}, 60_000);
});
29 changes: 29 additions & 0 deletions apps/e2e/demo-e2e-stdio-transport/src/stdio-dispose-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Stdio entrypoint whose scope writes `DISPOSE_MARKER` from an `onDispose` callback, registered from a
* factory provider as the catalog shows.
*/
import { App, FrontMcpInstance, ScopeEntry } from '@frontmcp/sdk';
import { writeFile } from '@frontmcp/utils';

import { serverConfig } from './config.js';

class DisposeMarker {}

const disposeMarkerProvider = {
name: 'DisposeMarker',
provide: DisposeMarker,
inject: () => [ScopeEntry] as const,
useFactory: (scope: ScopeEntry) => {
const markerPath = process.env['DISPOSE_MARKER'];
if (markerPath) scope.onDispose(() => writeFile(markerPath, 'disposed'));
return new DisposeMarker();
},
};

@App({ name: 'Dispose', providers: [disposeMarkerProvider] })
class DisposeApp {}

FrontMcpInstance.runStdio({ ...serverConfig, apps: [...serverConfig.apps, DisposeApp] }).catch((err) => {
console.error('Failed to start stdio server:', err);
process.exit(1);
});
57 changes: 42 additions & 15 deletions apps/e2e/demo-e2e-unix-socket/e2e/unix-socket.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
* Verifies the full MCP request lifecycle over Unix domain sockets.
*/

import * as os from 'node:os';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';

import { FrontMcpInstance } from '@frontmcp/sdk';
import { serverConfig } from '../src/config';

import { notesStore } from '../src/apps/notes/data/notes.store';
import { serverConfig } from '../src/config';
import { httpOverSocket, UnixSocketMcpClient } from './helpers/unix-socket-client';

/**
Expand Down Expand Up @@ -98,13 +101,10 @@ describe('Unix Socket Transport E2E', () => {
expect(fs.existsSync(socketPath)).toBe(true);
});

it('should set socket file permissions to 0o660', async () => {
it('creates the socket owner-only (0600), so the group cannot connect', async () => {
await startServer();

const stats = fs.statSync(socketPath);
// Extract permission bits (last 9 bits)
const perms = stats.mode & 0o777;
expect(perms).toBe(0o660);
expect(fs.lstatSync(socketPath).mode & 0o777).toBe(0o600);
});

it('should clean up socket file on close', async () => {
Expand All @@ -118,19 +118,46 @@ describe('Unix Socket Transport E2E', () => {
expect(fs.existsSync(socketPath)).toBe(false);
});

it('should handle stale socket file on restart', async () => {
// Create a stale socket file
fs.writeFileSync(socketPath, '');
expect(fs.existsSync(socketPath)).toBe(true);
it('replaces a stale socket a crashed server left behind', async () => {
const script = `require('net').createServer().listen(${JSON.stringify(socketPath)}, () => process.kill(process.pid, 'SIGKILL'))`;
spawnSync(process.execPath, ['-e', script]);
expect(fs.lstatSync(socketPath).isSocket()).toBe(true);

// Starting should clean up the stale file and proceed
await startServer();

expect(fs.existsSync(socketPath)).toBe(true);
// Verify the server is actually functional
const response = await httpOverSocket(socketPath, { path: '/health' });
expect(response.statusCode).toBe(200);
});

it('refuses to remove a regular file at the socket path', async () => {
fs.writeFileSync(socketPath, 'not a socket');

await expect(startServer()).rejects.toThrow(/not a socket/);
expect(fs.readFileSync(socketPath, 'utf8')).toBe('not a socket');
});

it('refuses to take over a live socket, and the first server keeps serving', async () => {
await startServer();

await expect(FrontMcpInstance.runUnixSocket({ ...serverConfig, socketPath })).rejects.toThrow(/already in use/);

const response = await httpOverSocket(socketPath, { path: '/health' });
expect(response.statusCode).toBe(200);
});

it('refuses a world-writable directory without the sticky bit', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-'));
fs.chmodSync(directory, 0o777);
const unsafePath = path.join(directory, 's.sock');
try {
await expect(FrontMcpInstance.runUnixSocket({ ...serverConfig, socketPath: unsafePath })).rejects.toThrow(
`${directory} (mode 0777)`,
);
expect(fs.existsSync(unsafePath)).toBe(false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
});

// ─── Health Endpoint ───────────────────────────────────────────────────────
Expand Down
Loading
Loading