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
3 changes: 0 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,8 @@ export function createExtractError(
}

export interface TidewaveConfig {
port?: number;
host?: string;
clientUrl?: string;
allowRemoteAccess?: boolean;
allowedOrigins?: string[];
projectName?: string;
framework?: string;
team?: {
Expand Down
9 changes: 7 additions & 2 deletions src/http/handlers/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Request, Response, NextFn, Handler } from '../index';
import { originNotAllowed, type Request, type Response, type NextFn, type Handler } from '../index';
import type { TidewaveConfig } from '../../core';
import { default as tidewavePackage } from '../../../package.json' with { type: 'json' };

export function createHandleConfig(config: TidewaveConfig): Handler {
return async function handleConfig(_req: Request, res: Response, next: NextFn): Promise<void> {
return async function handleConfig(req: Request, res: Response, next: NextFn): Promise<void> {
if (req.headers.origin) {
originNotAllowed(res);
return;
}

try {
const tidewaveConfig = {
project_name: config.projectName || 'app',
Expand Down
21 changes: 0 additions & 21 deletions src/http/handlers/html.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { Request, Response, NextFn, Handler } from '../index';
import type { TidewaveConfig } from '../../core';
import { default as tidewavePackage } from '../../../package.json' with { type: 'json' };

export function createHandleHtml(config: TidewaveConfig): Handler {
return async function handleHtml(req: Request, res: Response, next: NextFn): Promise<void> {
// Only handle exact /tidewave path, not sub-paths
Expand All @@ -15,12 +13,6 @@ export function createHandleHtml(config: TidewaveConfig): Handler {

try {
const clientUrl = config.clientUrl || 'https://tidewave.ai';
const tidewaveConfig = {
project_name: config.projectName || 'app',
framework_type: config.framework || 'unknown',
tidewave_version: tidewavePackage.version,
team: config.team || {},
};

res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
Expand All @@ -29,7 +21,6 @@ export function createHandleHtml(config: TidewaveConfig): Handler {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="tidewave:config" content="${escapeHtml(JSON.stringify(tidewaveConfig))}" />
<script type="module" src="${clientUrl}/tc/tc.js"></script>
</head>
<body></body>
Expand All @@ -48,15 +39,3 @@ export function createHandleHtml(config: TidewaveConfig): Handler {
}
};
}

function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};

return text.replace(/[&<>"']/g, match => map[match]!);
}
7 changes: 6 additions & 1 deletion src/http/handlers/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { methodNotAllowed, type Request, type Response, type NextFn } from '..';
import { methodNotAllowed, originNotAllowed, type Request, type Response, type NextFn } from '..';
import { serveMcp } from '../../mcp';

export async function handleMcp(req: Request, res: Response, next: NextFn): Promise<void> {
try {
if (req.headers.origin) {
originNotAllowed(res);
return;
}

if (req.method !== 'POST') {
methodNotAllowed(res);
return;
Expand Down
21 changes: 9 additions & 12 deletions src/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { ServerResponse } from 'http';
import type { IncomingMessage, NextFunction, Server } from 'connect';
import connect from 'connect';
import http from 'node:http';
import { checkOrigin, checkRemoteIp } from './security';
import { checkRemoteIp } from './security';
import { handleMcp } from './handlers/mcp';
import { createHandleHtml } from './handlers/html';
import { createHandleConfig } from './handlers/config';
Expand All @@ -16,12 +15,8 @@ export type Response = ServerResponse<IncomingMessage>;
export type NextFn = NextFunction;

export const ENDPOINT = '/tidewave' as const;
const DEFAULT_PORT = 5001 as const;
const DEFAULT_OPTIONS: TidewaveConfig = {
allowRemoteAccess: false,
allowedOrigins: [],
port: 5001,
host: 'localhost',
} as const;

export type Handler = (req: Request, res: Response, next: NextFn) => Promise<void>;
Expand Down Expand Up @@ -51,14 +46,9 @@ export function configureServer(
return server;
}

export function serve(server: Server, config: TidewaveConfig = DEFAULT_OPTIONS): void {
http.createServer(server).listen(config.port || DEFAULT_PORT);
}

export function checkSecurity(config: TidewaveConfig) {
return (req: Request, res: Response, next: NextFn): void => {
if (!checkRemoteIp(req, res, config)) return;
if (!checkOrigin(req, res, config)) return;
next();
};
}
Expand All @@ -67,7 +57,14 @@ export function methodNotAllowed(res: Response): void {
res.statusCode = 405;
res.setHeader('Allow', 'POST');
res.end();
return;
}

export function originNotAllowed(res: Response): void {
const message =
'For security reasons, Tidewave does not accept requests with an origin header for this endpoint.';
console.warn(message);
res.statusCode = 403;
res.end(message);
}

// Export for use by framework integrations
Expand Down
71 changes: 0 additions & 71 deletions src/http/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,74 +44,3 @@ export function isLocalIp(ip?: string): boolean {

return false;
}

export function checkOrigin(req: Request, res: Response, config: TidewaveConfig): boolean {
const { origin } = req.headers;

// No origin header means non-browser request (e.g. Claude Code, Cursor)
if (!origin) return true;

const allowedOrigins = config.allowedOrigins || getDefaultAllowedOrigins(config);
const originUrl = parseUrl(origin);

if (!originUrl) {
const message = `For security reasons, Tidewave only accepts requests from allowed origins.\n\nInvalid origin: ${origin}`;
console.warn(message);
res.statusCode = 403;
res.end(message);
return false;
}

const isAllowed = allowedOrigins.some(allowed => isOriginAllowed(originUrl, parseUrl(allowed)));

if (!isAllowed) {
const message = `For security reasons, Tidewave only accepts requests from the same origin your web app is running on.\n\nIf you really want to allow remote connections, configure the Tidewave with the \`allowedOrigins: [${JSON.stringify(origin)}]\` option.`;
console.warn(message);
res.statusCode = 403;
res.end(message);
return false;
}

return true;
}

export function getDefaultAllowedOrigins(config: TidewaveConfig): string[] {
const { host, port } = config;
if (!(host || port)) return [];
return [`http://${host}:${port}`, `https://${host}:${port}`];
}

export function parseUrl(url: string): { scheme?: string; host: string; port?: number } | null {
try {
const isProtocolRelative = url.startsWith('//');
const parsed = new URL(isProtocolRelative ? 'http:' + url : url);
return {
scheme: isProtocolRelative ? undefined : parsed.protocol?.slice(0, -1),
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : undefined,
};
} catch {
return null;
}
}

export function isOriginAllowed(
origin: ReturnType<typeof parseUrl>,
allowed: ReturnType<typeof parseUrl>,
): boolean {
if (!origin || !allowed) return false;

// Check scheme (if specified in allowed)
if (allowed.scheme && origin.scheme !== allowed.scheme) return false;

// Check port (if specified in allowed)
if (allowed.port && origin.port !== allowed.port) return false;

// Check host with wildcard support
if (allowed.host.startsWith('*.')) {
const allowedDomain = allowed.host.slice(2);
return origin.host === allowedDomain || origin.host.endsWith('.' + allowedDomain);
}

return origin.host === allowed.host;
}
6 changes: 0 additions & 6 deletions src/next-js/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,6 @@ export async function tidewaveHandler(
return res.status(404).json({ message: 'This route only works when accessed at /tidewave' });
}

if (origin) {
const [hostname, port] = origin.split(':');
config.host = hostname ? hostname : config.host;
config.port = port ? Number(port) : config.port;
}

const next: () => void = () => {};
const securityMiddleware = checkSecurity(config);
await connectWrapper(securityMiddleware)(req, res, next);
Expand Down
28 changes: 1 addition & 27 deletions src/vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ import { patchConsole } from './logger/console-patch';
patchConsole();

const DEFAULT_CONFIG: TidewaveConfig = {
port: 5173,
host: 'localhost',
allowRemoteAccess: false,
} as const;

export default function tidewave(
config: TidewaveConfig = { port: 5173, host: 'localhost' },
): Plugin {
export default function tidewave(config: TidewaveConfig = {}): Plugin {
return {
name: 'vite-plugin-tidewave',
configureServer: server => tidewaveServer(server, config),
Expand All @@ -25,28 +21,6 @@ async function tidewaveServer(
server: ViteDevServer,
config: TidewaveConfig = DEFAULT_CONFIG,
): Promise<void> {
const { config: serverConfig } = server;
const { host, port } = serverConfig.server;

if (port) {
config.port = port;
}

if (typeof host === 'string') {
config.host = host;
} else if (host === undefined) {
// The host can be undefined, in which case the default is localhost,
// see https://vite.dev/config/server-options#server-host.
config.host = 'localhost';
}

if (!(config.host || config.port)) {
console.error(
`[Tidewave] should have both host and port configured, got: host: ${host} port: ${port}`,
);
return;
}

// Set framework and projectName upfront
config.framework = 'vite';
config.projectName = config.projectName || (await getProjectName('vite_app'));
Expand Down
37 changes: 36 additions & 1 deletion test/http/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { methodNotAllowed } from '../../src/http';
import type { Response } from '../../src/http';
import type { Request, Response } from '../../src/http';
import { handleMcp } from '../../src/http/handlers/mcp';
import { createHandleConfig } from '../../src/http/handlers/config';

// Mock request/response helpers
const createMockRequest = (headers: Record<string, string> = {}): Partial<Request> => ({
socket: { remoteAddress: '127.0.0.1' } as any,
headers,
});

const createMockResponse = () => {
const mockEnd = vi.fn();
const mockSetHeader = vi.fn();
Expand All @@ -23,6 +30,7 @@ const createMockResponse = () => {
describe('HTTP Utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
console.warn = vi.fn();
console.error = vi.fn();
});

Expand All @@ -45,4 +53,31 @@ describe('HTTP Utilities', () => {
expect(result).toBeUndefined();
});
});

describe('handleMcp', () => {
it('should return 403 if origin header is set', async () => {
const req = createMockRequest({ origin: 'http://localhost:4000' });
const { res, mockEnd } = createMockResponse();
const next = vi.fn();

await handleMcp(req as Request, res as Response, next);

expect(res.statusCode).toBe(403);
expect(mockEnd).toHaveBeenCalledWith(expect.stringContaining('origin'));
});
});

describe('handleConfig', () => {
it('should return 403 if origin header is set', async () => {
const req = createMockRequest({ origin: 'http://localhost:4000' });
const { res, mockEnd } = createMockResponse();
const next = vi.fn();

const handler = createHandleConfig({});
await handler(req as Request, res as Response, next);

expect(res.statusCode).toBe(403);
expect(mockEnd).toHaveBeenCalledWith(expect.stringContaining('origin'));
});
});
});
Loading