diff --git a/src/core.ts b/src/core.ts index 1972824..c2346a3 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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?: { diff --git a/src/http/handlers/config.ts b/src/http/handlers/config.ts index d7cce88..d1e3f9d 100644 --- a/src/http/handlers/config.ts +++ b/src/http/handlers/config.ts @@ -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 { + return async function handleConfig(req: Request, res: Response, next: NextFn): Promise { + if (req.headers.origin) { + originNotAllowed(res); + return; + } + try { const tidewaveConfig = { project_name: config.projectName || 'app', diff --git a/src/http/handlers/html.ts b/src/http/handlers/html.ts index 59c6297..3092811 100644 --- a/src/http/handlers/html.ts +++ b/src/http/handlers/html.ts @@ -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 { // Only handle exact /tidewave path, not sub-paths @@ -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'); @@ -29,7 +21,6 @@ export function createHandleHtml(config: TidewaveConfig): Handler { - @@ -48,15 +39,3 @@ export function createHandleHtml(config: TidewaveConfig): Handler { } }; } - -function escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - - return text.replace(/[&<>"']/g, match => map[match]!); -} diff --git a/src/http/handlers/mcp.ts b/src/http/handlers/mcp.ts index 1f4384a..5eff29a 100644 --- a/src/http/handlers/mcp.ts +++ b/src/http/handlers/mcp.ts @@ -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 { try { + if (req.headers.origin) { + originNotAllowed(res); + return; + } + if (req.method !== 'POST') { methodNotAllowed(res); return; diff --git a/src/http/index.ts b/src/http/index.ts index 215bae2..aeec8ce 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -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'; @@ -16,12 +15,8 @@ export type Response = ServerResponse; 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; @@ -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(); }; } @@ -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 diff --git a/src/http/security.ts b/src/http/security.ts index b35944d..168448f 100644 --- a/src/http/security.ts +++ b/src/http/security.ts @@ -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, - allowed: ReturnType, -): 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; -} diff --git a/src/next-js/handler.ts b/src/next-js/handler.ts index 8a036c2..ac94705 100644 --- a/src/next-js/handler.ts +++ b/src/next-js/handler.ts @@ -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); diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 2061aa0..8182af4 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -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), @@ -25,28 +21,6 @@ async function tidewaveServer( server: ViteDevServer, config: TidewaveConfig = DEFAULT_CONFIG, ): Promise { - 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')); diff --git a/test/http/index.test.ts b/test/http/index.test.ts index c666c4c..69ea44c 100644 --- a/test/http/index.test.ts +++ b/test/http/index.test.ts @@ -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 = {}): Partial => ({ + socket: { remoteAddress: '127.0.0.1' } as any, + headers, +}); + const createMockResponse = () => { const mockEnd = vi.fn(); const mockSetHeader = vi.fn(); @@ -23,6 +30,7 @@ const createMockResponse = () => { describe('HTTP Utilities', () => { beforeEach(() => { vi.clearAllMocks(); + console.warn = vi.fn(); console.error = vi.fn(); }); @@ -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')); + }); + }); }); diff --git a/test/http/security.test.ts b/test/http/security.test.ts index b945016..9f74010 100644 --- a/test/http/security.test.ts +++ b/test/http/security.test.ts @@ -1,19 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - checkRemoteIp, - checkOrigin, - isLocalIp, - parseUrl, - isOriginAllowed, - getDefaultAllowedOrigins, -} from '../../src/http/security'; +import { checkRemoteIp, isLocalIp } from '../../src/http/security'; import type { Request, Response } from '../../src/http'; import type { TidewaveConfig } from '../../src/core'; // Mock request/response helpers -const createMockRequest = (remoteAddress = '127.0.0.1', origin?: string): Partial => ({ +const createMockRequest = (remoteAddress = '127.0.0.1'): Partial => ({ socket: { remoteAddress } as any, - headers: origin ? { origin } : {}, + headers: {}, }); const createMockResponse = () => { @@ -106,156 +99,4 @@ describe('HTTP Security', () => { expect(res.statusCode).toBe(200); }); }); - - describe('parseUrl', () => { - it('should parse complete URLs', () => { - const result = parseUrl('https://example.com:8080'); - expect(result).toEqual({ - scheme: 'https', - host: 'example.com', - port: 8080, - }); - }); - - it('should parse URLs without port', () => { - const result = parseUrl('http://localhost'); - expect(result).toEqual({ - scheme: 'http', - host: 'localhost', - port: undefined, - }); - }); - - it('should handle protocol-relative URLs', () => { - const result = parseUrl('//example.com'); - expect(result).toEqual({ - scheme: undefined, - host: 'example.com', - port: undefined, - }); - }); - - it('should return null for invalid URLs', () => { - expect(parseUrl('not-a-url')).toBe(null); - expect(parseUrl('')).toBe(null); - }); - }); - - describe('isOriginAllowed', () => { - it('should match exact origins', () => { - const origin = parseUrl('https://example.com:8080'); - const allowed = parseUrl('https://example.com:8080'); - - expect(isOriginAllowed(origin, allowed)).toBe(true); - }); - - it('should handle scheme flexibility', () => { - const origin = parseUrl('https://example.com'); - const allowed = parseUrl('//example.com'); // no scheme specified - - expect(isOriginAllowed(origin, allowed)).toBe(true); - }); - - it('should handle port flexibility', () => { - const origin = parseUrl('https://example.com:443'); - const allowed = parseUrl('https://example.com'); // no port specified - - expect(isOriginAllowed(origin, allowed)).toBe(true); - }); - - it('should support wildcard domains', () => { - const origin1 = parseUrl('https://sub.example.com'); - const origin2 = parseUrl('https://example.com'); - const allowed = { scheme: 'https' as const, host: '*.example.com', port: undefined }; - - expect(isOriginAllowed(origin1, allowed)).toBe(true); - expect(isOriginAllowed(origin2, allowed)).toBe(true); - }); - - it('should reject mismatched origins', () => { - const origin = parseUrl('https://evil.com'); - const allowed = parseUrl('https://example.com'); - - expect(isOriginAllowed(origin, allowed)).toBe(false); - }); - - it('should handle null inputs', () => { - expect(isOriginAllowed(null, null)).toBe(false); - expect(isOriginAllowed(parseUrl('https://example.com'), null)).toBe(false); - expect(isOriginAllowed(null, parseUrl('https://example.com'))).toBe(false); - }); - }); - - describe('getDefaultAllowedOrigins', () => { - it('should generate default origins from Vite config', () => { - const config: TidewaveConfig = { host: 'localhost', port: 3000 }; - - const origins = getDefaultAllowedOrigins(config); - - expect(origins).toEqual(['http://localhost:3000', 'https://localhost:3000']); - }); - }); - - describe('checkOrigin', () => { - it('should allow requests without origin header', () => { - const req = createMockRequest('127.0.0.1'); // no origin - const { res } = createMockResponse(); - const config: TidewaveConfig = { port: 5173, host: 'localhost' }; - - const result = checkOrigin(req as Request, res as Response, config); - - expect(result).toBe(true); - expect(res.statusCode).toBe(200); - }); - - it('should allow default Vite dev server origin', () => { - const req = createMockRequest('127.0.0.1', 'http://localhost:5173'); - const { res } = createMockResponse(); - const config: TidewaveConfig = { host: 'localhost', port: 5173 }; - - const result = checkOrigin(req as Request, res as Response, config); - - expect(result).toBe(true); - expect(res.statusCode).toBe(200); - }); - - it('should allow custom allowed origins', () => { - const req = createMockRequest('127.0.0.1', 'https://custom.example.com'); - const { res } = createMockResponse(); - const config: TidewaveConfig = { - allowedOrigins: ['https://custom.example.com'], - host: 'localhost', - port: 5173, - }; - - const result = checkOrigin(req as Request, res as Response, config); - - expect(result).toBe(true); - expect(res.statusCode).toBe(200); - }); - - it('should block unauthorized origins', () => { - const req = createMockRequest('127.0.0.1', 'https://evil.com'); - const { res, mockEnd } = createMockResponse(); - const config: TidewaveConfig = { host: 'localhost', port: 5173 }; - - const result = checkOrigin(req as Request, res as Response, config); - - expect(result).toBe(false); - expect(res.statusCode).toBe(403); - expect(mockEnd).toHaveBeenCalledWith(expect.stringContaining('security reasons')); - }); - - it('should handle invalid origin header', () => { - const req = createMockRequest('127.0.0.1', 'not-a-url'); - const { res, mockEnd } = createMockResponse(); - const config: TidewaveConfig = { host: 'localhost', port: 5173 }; - - const result = checkOrigin(req as Request, res as Response, config); - - expect(result).toBe(false); - expect(res.statusCode).toBe(403); - expect(mockEnd).toHaveBeenCalledWith(expect.stringContaining('Invalid origin')); - }); - }); }); diff --git a/test/vite-plugin.test.ts b/test/vite-plugin.test.ts index 9bd0291..600fd76 100644 --- a/test/vite-plugin.test.ts +++ b/test/vite-plugin.test.ts @@ -39,7 +39,6 @@ describe('Tidewave Vite Plugin', () => { it('should create plugin with custom config', () => { const config: TidewaveConfig = { allowRemoteAccess: true, - allowedOrigins: ['https://example.com'], }; const plugin = tidewave(config); @@ -89,7 +88,6 @@ describe('Tidewave Vite Plugin', () => { it('should pass config to configureServer', async () => { const config: TidewaveConfig = { allowRemoteAccess: false, - allowedOrigins: ['https://custom.com'], }; const mockServer = createMockServer(); @@ -122,22 +120,9 @@ describe('Tidewave Vite Plugin', () => { expect(() => tidewave({ allowRemoteAccess: false })).not.toThrow(); }); - it('should accept valid allowedOrigins arrays', () => { - const origins = [ - 'https://example.com', - 'http://localhost:3000', - '//sub.domain.com', - 'https://*.example.com', - ]; - - expect(() => tidewave({ allowedOrigins: origins })).not.toThrow(); - expect(() => tidewave({ allowedOrigins: [] })).not.toThrow(); - }); - it('should accept combined configuration options', () => { const config: TidewaveConfig = { allowRemoteAccess: true, - allowedOrigins: ['https://trusted.com', 'http://localhost:8080'], }; expect(() => tidewave(config)).not.toThrow();