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
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import {

const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [
{ entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_283_161 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_290_033 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_290_236 },
];

const VITE_INITIAL_CHUNK_MAX_BYTES = 762_815;
const VITE_INITIAL_CHUNK_MAX_BYTES = 762_830;

// What create() never needs: the parser behind @enclave-vm/ast (#885), the MCP client (#886) and server-only code
const NEVER_BUNDLED: Record<string, RegExp> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@
"InvalidFileSignalError": "class",
"InvalidHookFlowError": "class",
"InvalidHttpRouteError": "class",
"InvalidHttpRouteResponseError": "class",
"InvalidInputError": "class",
"InvalidInstructionSourceError": "class",
"InvalidMethodError": "class",
Expand Down
7 changes: 7 additions & 0 deletions apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ test.describe('Web host E2E', () => {
expect(await (await fetch(`${baseUrl}/status`)).json()).toEqual({ ok: true });
});

test('a page redirects to a path on the same server (#1053)', async ({ server }) => {
const response = await fetch(`${server.info.baseUrl}/home`, { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/board');
});

test('refuses a code posted from another site', async ({ server }) => {
const code = await signInCode(server, startupToken(server));

Expand Down
9 changes: 8 additions & 1 deletion apps/e2e/demo-e2e-web-host/src/apps/board/board.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HttpRoute, HttpRouteContext } from '@frontmcp/sdk';
import { httpRespond, HttpRoute, HttpRouteContext } from '@frontmcp/sdk';

import { board, boardStore } from './board.store';
import { VisitCounter } from './visit-counter.provider';
Expand Down Expand Up @@ -34,3 +34,10 @@ export class StatusRoute extends HttpRouteContext {
return { ok: true };
}
}

@HttpRoute({ method: 'GET', path: '/home', auth: 'public' })
export class HomeRedirect extends HttpRouteContext {
handle() {
return httpRespond.redirect('/board');
}
}
4 changes: 2 additions & 2 deletions apps/e2e/demo-e2e-web-host/src/apps/board/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { App } from '@frontmcp/sdk';

import { AddCardRoute, BoardEvents, BoardPage, StatusRoute } from './board.routes';
import { AddCardRoute, BoardEvents, BoardPage, HomeRedirect, StatusRoute } from './board.routes';
import { AddCardTool, RecordVisitTool } from './board.tools';
import { VisitCounter } from './visit-counter.provider';

Expand All @@ -9,6 +9,6 @@ import { VisitCounter } from './visit-counter.provider';
description: 'A board that agents change through tools and people see through pages',
providers: [VisitCounter],
tools: [AddCardTool, RecordVisitTool],
routes: [BoardPage, AddCardRoute, BoardEvents, StatusRoute],
routes: [BoardPage, AddCardRoute, BoardEvents, StatusRoute, HomeRedirect],
})
export class BoardApp {}
28 changes: 28 additions & 0 deletions libs/sdk/src/common/schemas/__tests__/http-output.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { httpOutputSchema, HttpRedirectSchema, httpRespond } from '../http-output.schema';

/** A redirect's `Location` is an absolute URL or a reference relative to the request (RFC 9110 section 10.2.2) (#1053). */
describe('HttpRedirectSchema', () => {
it.each(['/board', 'board?view=week', '../board#today', '?page=2', 'https://desk.example.com/board'])(
'takes %s as a location',
(location) => {
expect(HttpRedirectSchema.safeParse(httpRespond.redirect(location)).success).toBe(true);
},
);

it.each([
['an empty location', ''],
['a line break that would split the header', '/board\r\nSet-Cookie: session=forged'],
['a space', '/my board'],
['a tab', '/board\t'],
['a NUL character', '/board\u0000'],
['an absolute URL that does not parse', 'http://[desk'],
])('refuses %s', (_case, location) => {
expect(HttpRedirectSchema.safeParse(httpRespond.redirect(location)).success).toBe(false);
});

it('defaults the status to 302', () => {
const parsed = httpOutputSchema.parse({ kind: 'redirect', location: '/board' });

expect(parsed).toMatchObject({ kind: 'redirect', status: 302, location: '/board' });
});
});
18 changes: 17 additions & 1 deletion libs/sdk/src/common/schemas/http-output.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,30 @@ const HttpMeta = z.object({

const statusAllowsBody = (s: number) => !NO_BODY_STATUSES.includes(s as (typeof NO_BODY_STATUSES)[number]);

/**
* A `Location`: an absolute URL or a reference relative to the request, such as `/board` (RFC 9110 section 10.2.2),
* with no whitespace or control character, which could end the header.
*/
function isLocation(location: string): boolean {
if (/[\s\p{Cc}]/u.test(location)) return false;
try {
new URL(location, 'http://localhost');
return true;
} catch {
return false;
}
}

/**
* Redirect (3xx)
*/
export const HttpRedirectSchema = z
.object({
kind: z.literal('redirect'),
status: HttpRedirectStatus.default(302),
location: z.string().url(),
location: z.string().min(1).refine(isLocation, {
message: 'A redirect location is an absolute URL or a path, with no whitespace or control characters',
}),
})
.merge(HttpMeta);

Expand Down
10 changes: 10 additions & 0 deletions libs/sdk/src/errors/http-route.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ export class InvalidHttpRouteError extends InternalMcpError {
}
}

/** A route's `handle()` returned an output of an `httpRespond` kind that is not a valid one; the request fails with 500. */
export class InvalidHttpRouteResponseError extends InternalMcpError {
constructor(method: string, path: string, kind: string, problems: string) {
super(
`HTTP route ${method} ${path} returned an invalid "${kind}" response: ${problems}`,
'INVALID_HTTP_ROUTE_RESPONSE',
);
}
}

/** Thrown at boot for an `http.auth.people` the server cannot use. */
export class InvalidPeopleAuthOptionsError extends InternalMcpError {
constructor(problems: readonly string[]) {
Expand Down
1 change: 1 addition & 0 deletions libs/sdk/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export { BrowserEntryPointUnavailableError, type BrowserRefusedEntryPoint } from
export {
CrossOriginRequestError,
InvalidHttpRouteError,
InvalidHttpRouteResponseError,
InvalidPeopleAuthOptionsError,
PersonNotSignedInError,
SignInLockedError,
Expand Down
153 changes: 153 additions & 0 deletions libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,76 @@ class FailingRoute extends HttpRouteContext {
}
}

@HttpRoute({ method: 'GET', path: '/to/board', auth: 'public' })
class RelativeRedirectRoute extends HttpRouteContext {
handle() {
return httpRespond.redirect('/board');
}
}

@HttpRoute({ method: 'GET', path: '/to/desk', auth: 'public' })
class AbsoluteRedirectRoute extends HttpRouteContext {
handle() {
return httpRespond.redirect('https://desk.example.com/board');
}
}

@HttpRoute({ method: 'GET', path: '/moved', auth: 'public' })
class MovedRoute extends HttpRouteContext {
handle() {
return { ...httpRespond.redirect('/board'), status: 301 as const };
}
}

@HttpRoute({ method: 'GET', path: '/to/split', auth: 'public' })
class SplitHeaderRedirectRoute extends HttpRouteContext {
handle() {
return httpRespond.redirect('/board\r\nSet-Cookie: session=forged');
}
}

@HttpRoute({ method: 'GET', path: '/json-redirect', auth: 'public' })
class InvalidJsonRoute extends HttpRouteContext {
handle() {
return { ...httpRespond.json({ board: 'kanban' }), status: 301 };
}
}

@HttpRoute({ method: 'GET', path: '/card', auth: 'public' })
class CardRoute extends HttpRouteContext {
handle() {
return { kind: 'card', title: 'Ship 1.11.1' };
}
}

@HttpRoute({ method: 'GET', path: '/message-part', auth: 'public' })
class MessagePartRoute extends HttpRouteContext {
handle() {
return { kind: 'text', text: 'hello' };
}
}

@HttpRoute({ method: 'GET', path: '/to/back', auth: 'public' })
class BackRedirectRoute extends HttpRouteContext {
handle() {
return httpRespond.redirect('back');
}
}

@HttpRoute({ method: 'GET', path: '/to/tokyo', auth: 'public' })
class UnicodeRedirectRoute extends HttpRouteContext {
handle() {
return httpRespond.redirect('/board/東京?city=Zürich');
}
}

@HttpRoute({ method: 'GET', path: '/to/default', auth: 'public' })
class StatuslessRedirectRoute extends HttpRouteContext {
handle() {
return { kind: 'redirect', location: '/board' };
}
}

@App({
id: 'web',
name: 'web',
Expand All @@ -150,6 +220,16 @@ class FailingRoute extends HttpRouteContext {
MyNoteEvents,
HomeRoute,
FailingRoute,
RelativeRedirectRoute,
AbsoluteRedirectRoute,
MovedRoute,
SplitHeaderRedirectRoute,
InvalidJsonRoute,
CardRoute,
MessagePartRoute,
BackRedirectRoute,
UnicodeRedirectRoute,
StatuslessRedirectRoute,
],
})
class WebApp {}
Expand Down Expand Up @@ -393,6 +473,79 @@ describe.each([
expect(response.status).toBe(500);
expect(await response.text()).not.toContain('the route failed');
});

it('redirects to a path, as httpRespond.redirect() names it (#1053)', async () => {
const response = await harness.send('/to/board', { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/board');
});

it('redirects to an absolute URL', async () => {
const response = await harness.send('/to/desk', { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('https://desk.example.com/board');
});

it('redirects with 302 when a redirect output has no status', async () => {
const response = await harness.send('/to/default', { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/board');
});

it('sends a location with characters outside ASCII percent-encoded as UTF-8', async () => {
const response = await harness.send('/to/tokyo', { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/board/%E6%9D%B1%E4%BA%AC?city=Z%C3%BCrich');
});

it("answers a redirect with the output's status", async () => {
const response = await harness.send('/moved', { redirect: 'manual' });

expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe('/board');
});

it.each([
['a redirect whose location would split the header', '/to/split'],
['a JSON output with a redirect status', '/json-redirect'],
])('answers 500 and logs the error for %s, never the output as data', async (_case, path) => {
const logged = jest.spyOn(harness.instance.getPrimaryScope()?.logger as Scope['logger'], 'error');
try {
const response = await harness.send(path, { redirect: 'manual' });

expect(response.status).toBe(500);
expect(response.headers.get('location')).toBeNull();
expect(response.headers.get('set-cookie')).toBeNull();
expect(await response.text()).not.toContain('kind');
expect(logged).toHaveBeenCalledWith(expect.stringContaining(`GET ${path}`));
} finally {
logged.mockRestore();
}
});

it.each([
['whose kind is not an httpRespond kind', '/card', { kind: 'card', title: 'Ship 1.11.1' }],
['with an httpRespond kind and no status', '/message-part', { kind: 'text', text: 'hello' }],
])('sends an object %s as JSON', async (_case, path, body) => {
const response = await harness.send(path);

expect(response.status).toBe(200);
expect(await response.json()).toEqual(body);
});

it('sends the location as given, never reading it from the Referer', async () => {
const response = await harness.send('/to/back', {
redirect: 'manual',
headers: { Referer: 'https://evil.example/phish' },
});

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('back');
});
});

describe('the same-origin check', () => {
Expand Down
Loading
Loading