Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b88cbc9
fix(sdk): check Origin on loopback, create Unix sockets owner-only, d…
frontegg-david Nov 2, 2026
fd0f6e6
fix(sdk): bound the release of a failed start by the shutdown deadline
frontegg-david Nov 2, 2026
4a1e49f
Merge remote-tracking branch 'origin/fix/02-typed-hook-context' into …
frontegg-david Nov 2, 2026
8fb696b
fix(sdk): refuse a socket directory another user owns and bound the s…
frontegg-david Nov 2, 2026
e074ed6
Merge remote-tracking branch 'origin/fix/02-typed-hook-context' into …
frontegg-david Nov 2, 2026
1cde482
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
8346884
fix(sdk): refuse socket paths a directory above could replace, and bi…
frontegg-david Nov 2, 2026
cadb421
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
a09e8c2
fix(sdk): check the directories a socket's symlinks lead to, and refu…
frontegg-david Nov 2, 2026
b6de35e
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
a6c0f92
fix(cli): harden the sockets directory only when a socket server starts
frontegg-david Nov 2, 2026
f3a8ef4
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
0d3e533
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
995f40d
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
32d2e9b
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
12a1de9
Merge remote-tracking branch 'refs/remotes/origin/fix/02-typed-hook-c…
frontegg-david Nov 2, 2026
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