Skip to content

Commit 9ef59e5

Browse files
committed
revert to old check origin code
1 parent 871eb57 commit 9ef59e5

8 files changed

Lines changed: 369 additions & 94 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,12 @@ Vite's `tidewave` accepts the configuration options below:
112112
even if your server listens on other interfaces, for security purposes. Read
113113
[our security guidelines for more information and when to allow remote access](https://hexdocs.pm/tidewave/security.html)
114114
(if you know what you are doing)
115-
- `allowedOrigins`: hosts or origins allowed to upload screenshots and
116-
recordings from the browser. By default, Tidewave uses Vite's configured
117-
server host, or `localhost` when Vite uses its implicit host. Ports are
118-
ignored, so `http://localhost:5173` and `http://localhost:4000` both allow
119-
`localhost`
115+
- `allowedOrigins`: a list of values matched against the `Origin` header to
116+
prevent cross origin and DNS rebinding attacks. Each value must be a string of
117+
shape `[scheme:]//host[:port]`, where both scheme and port are optional. The
118+
host may also start with `*`. Example: `["//localhost:8000", "//*.test"]`. By
119+
default, Tidewave allows Vite's configured server host and port, using
120+
`localhost` when Vite uses its implicit host
120121
- `tmpDir`: temporary directory Tidewave uses for screenshots and recordings.
121122
Defaults to `tmp`, storing files under `tmp/tidewave/screenshots` and
122123
`tmp/tidewave/recordings`

src/core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export function createExtractError(
142142
}
143143

144144
export interface TidewaveConfig {
145+
port?: number;
146+
host?: string;
145147
clientUrl?: string;
146148
allowRemoteAccess?: boolean;
147149
allowedOrigins?: string[];

src/http/handlers/upload.ts

Lines changed: 2 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@ import { mkdir, writeFile } from 'node:fs/promises';
33
import type { TidewaveConfig } from '../../core';
44
import type { TidewaveHandler, TidewaveNext, TidewaveRequest, TidewaveResponse } from '../types';
55
import { magicByteType } from '../magic-bytes';
6+
import { checkOrigin } from '../security';
67

78
const MAX_UPLOAD_SIZE = 200_000_000;
89
const ALLOWED_UPLOAD_CONTENT_TYPES = ['image/png', 'image/jpeg', 'video/webm'] as const;
910
const ALLOWED_UPLOAD_TYPES = ['screenshot', 'recording'] as const;
1011
const ALLOWED_UPLOAD_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webm'] as const;
1112
const INVALID_UPLOAD = 'Bad Request: missing or invalid file parameter';
12-
const INVALID_UPLOAD_ORIGIN =
13-
"For security reasons, this page only allows connections from the application's own origin.";
1413

1514
class InvalidUploadError extends Error {}
1615

@@ -36,7 +35,7 @@ export function createHandleUpload(config: TidewaveConfig): TidewaveHandler {
3635
return;
3736
}
3837

39-
if (!requireSameOrigin(req, res, config)) return;
38+
if (!checkOrigin(req, res, config)) return;
4039
if (uploadTooLarge(req)) {
4140
badRequest(res);
4241
return;
@@ -88,58 +87,6 @@ export function createHandleUpload(config: TidewaveConfig): TidewaveHandler {
8887
};
8988
}
9089

91-
function requireSameOrigin(
92-
req: TidewaveRequest,
93-
res: TidewaveResponse,
94-
config: TidewaveConfig,
95-
): boolean {
96-
const origin = firstHeaderValue(req.headers.origin);
97-
if (!origin) return true;
98-
99-
if (allowedOriginHosts(config).includes(originHost(origin))) return true;
100-
101-
console.warn(INVALID_UPLOAD_ORIGIN);
102-
res.statusCode = 403;
103-
res.end(INVALID_UPLOAD_ORIGIN);
104-
return false;
105-
}
106-
107-
function originHost(origin: string): string {
108-
try {
109-
return new URL(origin).hostname.toLowerCase();
110-
} catch {
111-
return '';
112-
}
113-
}
114-
115-
function allowedOriginHosts(config: TidewaveConfig): string[] {
116-
// Do not derive this from the request Host header. Host is client-controlled,
117-
// while configured origin hosts avoid accepting DNS rebinding requests.
118-
return (config.allowedOrigins || []).map(originOrHostToHost).filter(host => host.length > 0);
119-
}
120-
121-
function originOrHostToHost(originOrHost: string): string {
122-
if (originOrHost.startsWith('//')) {
123-
return originHost(`http:${originOrHost}`);
124-
}
125-
126-
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(originOrHost)) {
127-
return originHost(originOrHost);
128-
}
129-
130-
if (originOrHost.startsWith('[')) {
131-
const end = originOrHost.indexOf(']');
132-
return end === -1 ? originOrHost.toLowerCase() : originOrHost.slice(1, end).toLowerCase();
133-
}
134-
135-
try {
136-
const parsed = new URL(`http://${originOrHost}`);
137-
return parsed.hostname.toLowerCase();
138-
} catch {
139-
return originOrHost.toLowerCase();
140-
}
141-
}
142-
14390
function uploadTooLarge(req: TidewaveRequest): boolean {
14491
const contentLength = firstHeaderValue(req.headers['content-length']);
14592
if (!contentLength) return false;

src/http/security.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,81 @@ export function checkRemoteIp(
3939
return false;
4040
}
4141

42+
export function checkOrigin(
43+
req: TidewaveRequest,
44+
res: TidewaveResponse,
45+
config: TidewaveConfig,
46+
): boolean {
47+
const { origin } = req.headers;
48+
49+
// No origin header means non-browser request (e.g. Claude Code, Cursor)
50+
if (!origin) return true;
51+
52+
const allowedOrigins = config.allowedOrigins || getDefaultAllowedOrigins(config);
53+
const originUrl = parseUrl(origin);
54+
55+
if (!originUrl) {
56+
const message = `For security reasons, Tidewave only accepts requests from allowed origins.\n\nInvalid origin: ${origin}`;
57+
console.warn(message);
58+
res.statusCode = 403;
59+
res.end(message);
60+
return false;
61+
}
62+
63+
const isAllowed = allowedOrigins.some(allowed => isOriginAllowed(originUrl, parseUrl(allowed)));
64+
65+
if (!isAllowed) {
66+
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.`;
67+
console.warn(message);
68+
res.statusCode = 403;
69+
res.end(message);
70+
return false;
71+
}
72+
73+
return true;
74+
}
75+
76+
export function getDefaultAllowedOrigins(config: TidewaveConfig): string[] {
77+
const { host, port } = config;
78+
if (!(host || port)) return [];
79+
return [`http://${host}:${port}`, `https://${host}:${port}`];
80+
}
81+
82+
export function parseUrl(url: string): { scheme?: string; host: string; port?: number } | null {
83+
try {
84+
const isProtocolRelative = url.startsWith('//');
85+
const parsed = new URL(isProtocolRelative ? 'http:' + url : url);
86+
return {
87+
scheme: isProtocolRelative ? undefined : parsed.protocol?.slice(0, -1),
88+
host: parsed.hostname,
89+
port: parsed.port ? parseInt(parsed.port) : undefined,
90+
};
91+
} catch {
92+
return null;
93+
}
94+
}
95+
96+
export function isOriginAllowed(
97+
origin: ReturnType<typeof parseUrl>,
98+
allowed: ReturnType<typeof parseUrl>,
99+
): boolean {
100+
if (!origin || !allowed) return false;
101+
102+
// Check scheme (if specified in allowed)
103+
if (allowed.scheme && origin.scheme !== allowed.scheme) return false;
104+
105+
// Check port (if specified in allowed)
106+
if (allowed.port && origin.port !== allowed.port) return false;
107+
108+
// Check host with wildcard support
109+
if (allowed.host.startsWith('*.')) {
110+
const allowedDomain = allowed.host.slice(2);
111+
return origin.host === allowedDomain || origin.host.endsWith('.' + allowedDomain);
112+
}
113+
114+
return origin.host === allowed.host;
115+
}
116+
42117
export function isLocalIp(ip?: string): boolean {
43118
if (!ip) return false;
44119

src/vite-plugin.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import { patchConsole } from './logger/console-patch';
77
patchConsole();
88

99
const DEFAULT_CONFIG: TidewaveConfig = {
10+
port: 5173,
11+
host: 'localhost',
1012
allowRemoteAccess: false,
1113
} as const;
1214

13-
export default function tidewave(config: TidewaveConfig = {}): Plugin {
15+
export default function tidewave(
16+
config: TidewaveConfig = { port: 5173, host: 'localhost' },
17+
): Plugin {
1418
return {
1519
name: 'vite-plugin-tidewave',
1620
configureServer: server => tidewaveServer(server, config),
@@ -21,10 +25,31 @@ async function tidewaveServer(
2125
server: ViteDevServer,
2226
config: TidewaveConfig = DEFAULT_CONFIG,
2327
): Promise<void> {
28+
const { config: serverConfig } = server;
29+
const { host, port } = serverConfig.server;
30+
31+
if (port) {
32+
config.port = port;
33+
}
34+
35+
if (typeof host === 'string') {
36+
config.host = host;
37+
} else if (host === undefined) {
38+
// The host can be undefined, in which case the default is localhost,
39+
// see https://vite.dev/config/server-options#server-host.
40+
config.host = 'localhost';
41+
}
42+
43+
if (!(config.host || config.port)) {
44+
console.error(
45+
`[Tidewave] should have both host and port configured, got: host: ${host} port: ${port}`,
46+
);
47+
return;
48+
}
49+
2450
// Set framework and projectName upfront
2551
config.framework = 'vite';
2652
config.projectName = config.projectName || (await getProjectName('vite_app'));
27-
config.allowedOrigins = config.allowedOrigins || defaultAllowedOrigins(server);
2853

2954
configureServer(server.middlewares, config, {
3055
getLocalPort: () => {
@@ -33,12 +58,3 @@ async function tidewaveServer(
3358
},
3459
});
3560
}
36-
37-
function defaultAllowedOrigins(server: ViteDevServer): string[] {
38-
const { host } = server.config.server;
39-
if (typeof host === 'string') return [host];
40-
41-
// The host can be undefined, in which case the default is localhost,
42-
// see https://vite.dev/config/server-options#server-host.
43-
return ['localhost'];
44-
}

0 commit comments

Comments
 (0)