···11import { SeamsAnnotationCard } from './annotation-card';
22+import { SeamsSidebar } from './sidebar';
2334export * from './annotation-card';
55+export * from './sidebar';
4657export function registerComponents() {
68 if (!customElements.get('seams-annotation-card')) {
79 customElements.define('seams-annotation-card', SeamsAnnotationCard);
1010+ }
1111+ if (!customElements.get('seams-sidebar')) {
1212+ customElements.define('seams-sidebar', SeamsSidebar);
813 }
914}
+178
packages/core/src/components/sidebar.ts
···11+/**
22+ * SeamsSidebar Web Component
33+ *
44+ * A self-contained sidebar component with Shadow DOM encapsulation.
55+ * Used by both the browser extension sidepanel and the proxy client.
66+ *
77+ * Usage:
88+ * ```typescript
99+ * import { SeamsSidebar, registerComponents } from '@seams/core';
1010+ *
1111+ * registerComponents();
1212+ *
1313+ * const sidebar = document.createElement('seams-sidebar') as SeamsSidebar;
1414+ * sidebar.storage = new BrowserStorageAdapter();
1515+ * sidebar.launcher = new ExtensionOAuthLauncher();
1616+ * sidebar.config = { oauth: {...}, pds: {...} };
1717+ * sidebar.onSyncNeeded = () => { ... };
1818+ *
1919+ * document.body.appendChild(sidebar);
2020+ *
2121+ * // Control the sidebar
2222+ * sidebar.setCurrentUrl('https://example.com');
2323+ * sidebar.setSelection({ text: '...', selectors: [...] });
2424+ * ```
2525+ */
2626+2727+import type { StorageAdapter } from '../storage';
2828+import type { OAuthLauncher, OAuthConfig } from '../oauth';
2929+import { Sidebar, type SidebarConfig, type SyncCallback } from '../sidebar';
3030+import { SIDEBAR_STYLES } from './sidebar-styles';
3131+3232+export class SeamsSidebar extends HTMLElement {
3333+ private _sidebar: Sidebar | null = null;
3434+ private _initialized = false;
3535+3636+ // Configuration properties - must be set before connectedCallback
3737+ private _storage: StorageAdapter | null = null;
3838+ private _launcher: OAuthLauncher | null = null;
3939+ private _config: SidebarConfig | null = null;
4040+ private _onSyncNeeded: SyncCallback | undefined;
4141+4242+ constructor() {
4343+ super();
4444+ this.attachShadow({ mode: 'open' });
4545+ }
4646+4747+ // Property setters for configuration
4848+ set storage(value: StorageAdapter) {
4949+ this._storage = value;
5050+ this.tryInitialize();
5151+ }
5252+5353+ get storage(): StorageAdapter | null {
5454+ return this._storage;
5555+ }
5656+5757+ set launcher(value: OAuthLauncher) {
5858+ this._launcher = value;
5959+ this.tryInitialize();
6060+ }
6161+6262+ get launcher(): OAuthLauncher | null {
6363+ return this._launcher;
6464+ }
6565+6666+ set config(value: SidebarConfig) {
6767+ this._config = value;
6868+ this.tryInitialize();
6969+ }
7070+7171+ get config(): SidebarConfig | null {
7272+ return this._config;
7373+ }
7474+7575+ set onSyncNeeded(value: SyncCallback | undefined) {
7676+ this._onSyncNeeded = value;
7777+ // Note: Can't update this on existing Sidebar instance, but that's fine
7878+ // since this is typically set before initialization
7979+ }
8080+8181+ get onSyncNeeded(): SyncCallback | undefined {
8282+ return this._onSyncNeeded;
8383+ }
8484+8585+ connectedCallback() {
8686+ this.tryInitialize();
8787+ }
8888+8989+ disconnectedCallback() {
9090+ // Cleanup if needed
9191+ this._sidebar = null;
9292+ this._initialized = false;
9393+ }
9494+9595+ /**
9696+ * Try to initialize the sidebar if all required properties are set
9797+ */
9898+ private tryInitialize() {
9999+ // Don't initialize twice
100100+ if (this._initialized) return;
101101+102102+ // Need all required properties
103103+ if (!this._storage || !this._launcher || !this._config) return;
104104+105105+ // Need to be connected to DOM
106106+ if (!this.isConnected) return;
107107+108108+ // Need shadow root
109109+ if (!this.shadowRoot) return;
110110+111111+ this._initialized = true;
112112+ this.render();
113113+ }
114114+115115+ private render() {
116116+ if (!this.shadowRoot || !this._storage || !this._launcher || !this._config) {
117117+ return;
118118+ }
119119+120120+ // Inject styles
121121+ const styleEl = document.createElement('style');
122122+ styleEl.textContent = SIDEBAR_STYLES;
123123+ this.shadowRoot.appendChild(styleEl);
124124+125125+ // Create container for Sidebar class to render into
126126+ const container = document.createElement('div');
127127+ container.id = 'sidebar-container';
128128+ this.shadowRoot.appendChild(container);
129129+130130+ // Initialize the Sidebar class
131131+ this._sidebar = new Sidebar(
132132+ container,
133133+ this._storage,
134134+ this._launcher,
135135+ this._config,
136136+ this._onSyncNeeded
137137+ );
138138+139139+ console.log('[seams-sidebar] Web component initialized');
140140+ }
141141+142142+ // Public API - delegates to internal Sidebar instance
143143+144144+ /**
145145+ * Set the current page URL for annotation loading
146146+ */
147147+ setCurrentUrl(url: string) {
148148+ this._sidebar?.setCurrentUrl(url);
149149+ }
150150+151151+ /**
152152+ * Get the current URL
153153+ */
154154+ getCurrentUrl(): string {
155155+ return this._sidebar?.getCurrentUrl() || '';
156156+ }
157157+158158+ /**
159159+ * Set the current text selection
160160+ */
161161+ setSelection(selection: { text: string; selectors: any[] } | null) {
162162+ this._sidebar?.setSelection(selection);
163163+ }
164164+165165+ /**
166166+ * Activate the annotation textarea (focus it)
167167+ */
168168+ handleActivateAnnotation() {
169169+ this._sidebar?.handleActivateAnnotation();
170170+ }
171171+172172+ /**
173173+ * Programmatically create an annotation
174174+ */
175175+ async createAnnotation(target: { source: string; selectors: any[] }, body: string) {
176176+ return this._sidebar?.createAnnotation(target, body);
177177+ }
178178+}
+11-4
packages/core/src/sidebar/index.ts
···235235 });
236236237237 document.addEventListener('click', (e) => {
238238- if (profileDropdown && profileAvatar &&
239239- !profileAvatar.contains(e.target as Node) &&
240240- !profileDropdown.contains(e.target as Node)) {
241241- profileDropdown.setAttribute('style', 'display: none;');
238238+ if (profileDropdown && profileAvatar) {
239239+ // Use composedPath() for Shadow DOM compatibility
240240+ // When events bubble out of a shadow root, e.target is retargeted to the shadow host
241241+ // composedPath() gives us the full path including elements inside shadow roots
242242+ const path = e.composedPath();
243243+ const clickedAvatar = path.includes(profileAvatar);
244244+ const clickedDropdown = path.includes(profileDropdown);
245245+246246+ if (!clickedAvatar && !clickedDropdown) {
247247+ profileDropdown.setAttribute('style', 'display: none;');
248248+ }
242249 }
243250 });
244251 }
+502-502
sure-client-proxy/cors-proxy/index.ts
···1717 * Headers: X-Seams-Timestamp (unix ms), X-Seams-Signature (base64)
1818 */
1919function verifyHmacSignature(timestamp: string, url: string, signature: string): boolean {
2020- if (!HMAC_SECRET) return false;
2121-2222- const message = `${timestamp}:${url}`;
2323- const expectedSignature = crypto
2424- .createHmac('sha256', HMAC_SECRET)
2525- .update(message)
2626- .digest('base64');
2727-2828- // Use timing-safe comparison to prevent timing attacks
2929- try {
3030- return crypto.timingSafeEqual(
3131- Buffer.from(signature, 'base64'),
3232- Buffer.from(expectedSignature, 'base64')
3333- );
3434- } catch {
3535- return false;
3636- }
2020+ if (!HMAC_SECRET) return false;
2121+2222+ const message = `${timestamp}:${url}`;
2323+ const expectedSignature = crypto
2424+ .createHmac('sha256', HMAC_SECRET)
2525+ .update(message)
2626+ .digest('base64');
2727+2828+ // Use timing-safe comparison to prevent timing attacks
2929+ try {
3030+ return crypto.timingSafeEqual(
3131+ Buffer.from(signature, 'base64'),
3232+ Buffer.from(expectedSignature, 'base64')
3333+ );
3434+ } catch {
3535+ return false;
3636+ }
3737}
38383939// Allowed origins for CORS (configurable via environment variable)
4040// Note: Use 127.0.0.1 for local dev (RFC 8252 requires loopback IP for OAuth)
4141const CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS
4242- ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim())
4343- : [
4444- 'http://127.0.0.1:8081',
4545- ];
4242+ ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim())
4343+ : [
4444+ 'http://127.0.0.1:8081',
4545+ ];
46464747// Configurable limits via environment variables
4848const MAX_BODY_SIZE = parseInt(process.env.CORS_PROXY_MAX_BODY_SIZE || String(10 * 1024 * 1024), 10);
···5757const rateLimitMap = new Map<string, { count: number; windowStart: number }>();
58585959function isRateLimited(clientId: string): boolean {
6060- const now = Date.now();
6161- const entry = rateLimitMap.get(clientId);
6262-6363- if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
6464- // Prevent memory exhaustion: if map is full, reject new clients
6565- if (rateLimitMap.size >= RATE_LIMIT_MAX_CLIENTS && !entry) {
6666- console.warn(`[cors-proxy] Rate limit map full (${RATE_LIMIT_MAX_CLIENTS} clients), rejecting new client`);
6767- return true;
6868- }
6969- rateLimitMap.set(clientId, { count: 1, windowStart: now });
7070- return false;
7171- }
7272-7373- entry.count++;
7474- if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
7575- return true;
7676- }
7777-7878- return false;
6060+ const now = Date.now();
6161+ const entry = rateLimitMap.get(clientId);
6262+6363+ if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
6464+ // Prevent memory exhaustion: if map is full, reject new clients
6565+ if (rateLimitMap.size >= RATE_LIMIT_MAX_CLIENTS && !entry) {
6666+ console.warn(`[cors-proxy] Rate limit map full (${RATE_LIMIT_MAX_CLIENTS} clients), rejecting new client`);
6767+ return true;
6868+ }
6969+ rateLimitMap.set(clientId, { count: 1, windowStart: now });
7070+ return false;
7171+ }
7272+7373+ entry.count++;
7474+ if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
7575+ return true;
7676+ }
7777+7878+ return false;
7979}
80808181// Clean up old rate limit entries periodically
8282setInterval(() => {
8383- const now = Date.now();
8484- for (const [key, entry] of rateLimitMap.entries()) {
8585- if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
8686- rateLimitMap.delete(key);
8787- }
8888- }
8383+ const now = Date.now();
8484+ for (const [key, entry] of rateLimitMap.entries()) {
8585+ if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
8686+ rateLimitMap.delete(key);
8787+ }
8888+ }
8989}, RATE_LIMIT_WINDOW_MS);
90909191// Check if an IP address is private/internal
9292function isPrivateIP(ip: string): { blocked: boolean; reason?: string } {
9393- // Handle IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
9494- const ipv4MappedMatch = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/i);
9595- if (ipv4MappedMatch) {
9696- const [, aStr, bStr] = ipv4MappedMatch;
9797- const a = Number(aStr);
9898- const b = Number(bStr);
9999- if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
100100- (a === 169 && b === 254) || a === 127 || a === 0) {
101101- return { blocked: true, reason: 'Private IPv4-mapped IPv6 address' };
102102- }
103103- }
104104-105105- // IPv6 private ranges
106106- if (ip.match(/^f[cd][0-9a-f]{2}:/i)) {
107107- return { blocked: true, reason: 'IPv6 Unique Local Address (ULA)' };
108108- }
109109- if (ip.match(/^fe[89ab][0-9a-f]:/i)) {
110110- return { blocked: true, reason: 'IPv6 link-local address' };
111111- }
112112- if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
113113- return { blocked: true, reason: 'IPv6 loopback' };
114114- }
115115-116116- // IPv4 private ranges
117117- const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
118118- if (ipv4Match) {
119119- const [, aStr, bStr] = ipv4Match;
120120- const a = Number(aStr);
121121- const b = Number(bStr);
122122-123123- if (a === 10) {
124124- return { blocked: true, reason: 'Private IP (10.0.0.0/8)' };
125125- }
126126- if (a === 172 && b >= 16 && b <= 31) {
127127- return { blocked: true, reason: 'Private IP (172.16.0.0/12)' };
128128- }
129129- if (a === 192 && b === 168) {
130130- return { blocked: true, reason: 'Private IP (192.168.0.0/16)' };
131131- }
132132- if (a === 169 && b === 254) {
133133- return { blocked: true, reason: 'Link-local IP (169.254.0.0/16)' };
134134- }
135135- if (a === 127) {
136136- return { blocked: true, reason: 'Loopback IP (127.0.0.0/8)' };
137137- }
138138- if (a === 0) {
139139- return { blocked: true, reason: 'Invalid IP (0.0.0.0/8)' };
140140- }
141141- if (a === 100 && b >= 64 && b <= 127) {
142142- return { blocked: true, reason: 'Carrier-Grade NAT (100.64.0.0/10)' };
143143- }
144144- }
145145-146146- return { blocked: false };
9393+ // Handle IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
9494+ const ipv4MappedMatch = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/i);
9595+ if (ipv4MappedMatch) {
9696+ const [, aStr, bStr] = ipv4MappedMatch;
9797+ const a = Number(aStr);
9898+ const b = Number(bStr);
9999+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
100100+ (a === 169 && b === 254) || a === 127 || a === 0) {
101101+ return { blocked: true, reason: 'Private IPv4-mapped IPv6 address' };
102102+ }
103103+ }
104104+105105+ // IPv6 private ranges
106106+ if (ip.match(/^f[cd][0-9a-f]{2}:/i)) {
107107+ return { blocked: true, reason: 'IPv6 Unique Local Address (ULA)' };
108108+ }
109109+ if (ip.match(/^fe[89ab][0-9a-f]:/i)) {
110110+ return { blocked: true, reason: 'IPv6 link-local address' };
111111+ }
112112+ if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
113113+ return { blocked: true, reason: 'IPv6 loopback' };
114114+ }
115115+116116+ // IPv4 private ranges
117117+ const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
118118+ if (ipv4Match) {
119119+ const [, aStr, bStr] = ipv4Match;
120120+ const a = Number(aStr);
121121+ const b = Number(bStr);
122122+123123+ if (a === 10) {
124124+ return { blocked: true, reason: 'Private IP (10.0.0.0/8)' };
125125+ }
126126+ if (a === 172 && b >= 16 && b <= 31) {
127127+ return { blocked: true, reason: 'Private IP (172.16.0.0/12)' };
128128+ }
129129+ if (a === 192 && b === 168) {
130130+ return { blocked: true, reason: 'Private IP (192.168.0.0/16)' };
131131+ }
132132+ if (a === 169 && b === 254) {
133133+ return { blocked: true, reason: 'Link-local IP (169.254.0.0/16)' };
134134+ }
135135+ if (a === 127) {
136136+ return { blocked: true, reason: 'Loopback IP (127.0.0.0/8)' };
137137+ }
138138+ if (a === 0) {
139139+ return { blocked: true, reason: 'Invalid IP (0.0.0.0/8)' };
140140+ }
141141+ if (a === 100 && b >= 64 && b <= 127) {
142142+ return { blocked: true, reason: 'Carrier-Grade NAT (100.64.0.0/10)' };
143143+ }
144144+ }
145145+146146+ return { blocked: false };
147147}
148148149149// SSRF Protection: Block private/internal IP ranges and cloud metadata endpoints
150150function isBlockedUrl(urlString: string): { blocked: boolean; reason?: string } {
151151- try {
152152- const url = new URL(urlString);
153153- let hostname = url.hostname.toLowerCase();
154154-155155- // Remove brackets from IPv6 addresses for validation
156156- if (hostname.startsWith('[') && hostname.endsWith(']')) {
157157- hostname = hostname.slice(1, -1);
158158- }
159159-160160- // Block cloud metadata endpoints by hostname
161161- const metadataHosts = [
162162- '169.254.169.254', // AWS/GCP/Azure metadata
163163- 'metadata.google.internal',
164164- 'metadata.google',
165165- '100.100.100.200', // Alibaba Cloud metadata
166166- 'fd00:ec2::254', // AWS IPv6 metadata
167167- ];
168168- if (metadataHosts.includes(hostname)) {
169169- return { blocked: true, reason: 'Cloud metadata endpoint blocked' };
170170- }
171171-172172- // Block localhost variants
173173- if (hostname === 'localhost' ||
174174- hostname === '127.0.0.1' ||
175175- hostname === '::1' ||
176176- hostname === '0.0.0.0' ||
177177- hostname.endsWith('.localhost')) {
178178- return { blocked: true, reason: 'Localhost access blocked' };
179179- }
180180-181181- // Check if hostname is a direct IP address
182182- const ipCheck = isPrivateIP(hostname);
183183- if (ipCheck.blocked) {
184184- return ipCheck;
185185- }
186186-187187- // Block file:// and other dangerous protocols
188188- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
189189- return { blocked: true, reason: `Protocol ${url.protocol} not allowed` };
190190- }
191191-192192- return { blocked: false };
193193- } catch {
194194- return { blocked: true, reason: 'Invalid URL format' };
195195- }
151151+ try {
152152+ const url = new URL(urlString);
153153+ let hostname = url.hostname.toLowerCase();
154154+155155+ // Remove brackets from IPv6 addresses for validation
156156+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
157157+ hostname = hostname.slice(1, -1);
158158+ }
159159+160160+ // Block cloud metadata endpoints by hostname
161161+ const metadataHosts = [
162162+ '169.254.169.254', // AWS/GCP/Azure metadata
163163+ 'metadata.google.internal',
164164+ 'metadata.google',
165165+ '100.100.100.200', // Alibaba Cloud metadata
166166+ 'fd00:ec2::254', // AWS IPv6 metadata
167167+ ];
168168+ if (metadataHosts.includes(hostname)) {
169169+ return { blocked: true, reason: 'Cloud metadata endpoint blocked' };
170170+ }
171171+172172+ // Block localhost variants
173173+ if (hostname === 'localhost' ||
174174+ hostname === '127.0.0.1' ||
175175+ hostname === '::1' ||
176176+ hostname === '0.0.0.0' ||
177177+ hostname.endsWith('.localhost')) {
178178+ return { blocked: true, reason: 'Localhost access blocked' };
179179+ }
180180+181181+ // Check if hostname is a direct IP address
182182+ const ipCheck = isPrivateIP(hostname);
183183+ if (ipCheck.blocked) {
184184+ return ipCheck;
185185+ }
186186+187187+ // Block file:// and other dangerous protocols
188188+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
189189+ return { blocked: true, reason: `Protocol ${url.protocol} not allowed` };
190190+ }
191191+192192+ return { blocked: false };
193193+ } catch {
194194+ return { blocked: true, reason: 'Invalid URL format' };
195195+ }
196196}
197197198198// Resolve hostname and validate resolved IPs against SSRF blocklist
···201201// resolution. For full protection, deploy behind a network-level firewall that
202202// blocks outbound connections to private IP ranges.
203203async function resolveAndValidate(urlString: string): Promise<{ blocked: boolean; reason?: string }> {
204204- try {
205205- const url = new URL(urlString);
206206- const hostname = url.hostname;
207207-208208- // Skip DNS resolution for IP addresses (already validated by isBlockedUrl)
209209- if (hostname.match(/^(\d+\.){3}\d+$/) || hostname.includes(':')) {
210210- return { blocked: false };
211211- }
212212-213213- // Resolve the hostname to IP addresses
214214- let addresses: string[];
215215- try {
216216- addresses = await dns.resolve4(hostname);
217217- } catch {
218218- // If IPv4 fails, try IPv6
219219- try {
220220- addresses = await dns.resolve6(hostname);
221221- } catch {
222222- // DNS resolution failed - fail closed for security
223223- return { blocked: true, reason: `DNS resolution failed for ${hostname}` };
224224- }
225225- }
226226-227227- // Validate ALL resolved IPs - block if ANY is private
228228- for (const ip of addresses) {
229229- const ipCheck = isPrivateIP(ip);
230230- if (ipCheck.blocked) {
231231- // Log detailed info server-side, return generic message to client
232232- console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to ${ip} (${ipCheck.reason})`);
233233- return { blocked: true, reason: 'Request blocked for security reasons' };
234234- }
235235-236236- // Also check cloud metadata IPs
237237- if (ip === '169.254.169.254' || ip === '100.100.100.200') {
238238- console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to cloud metadata IP ${ip}`);
239239- return { blocked: true, reason: 'Request blocked for security reasons' };
240240- }
241241- }
242242-243243- return { blocked: false };
244244- } catch {
245245- return { blocked: true, reason: 'Failed to validate URL' };
246246- }
204204+ try {
205205+ const url = new URL(urlString);
206206+ const hostname = url.hostname;
207207+208208+ // Skip DNS resolution for IP addresses (already validated by isBlockedUrl)
209209+ if (hostname.match(/^(\d+\.){3}\d+$/) || hostname.includes(':')) {
210210+ return { blocked: false };
211211+ }
212212+213213+ // Resolve the hostname to IP addresses
214214+ let addresses: string[];
215215+ try {
216216+ addresses = await dns.resolve4(hostname);
217217+ } catch {
218218+ // If IPv4 fails, try IPv6
219219+ try {
220220+ addresses = await dns.resolve6(hostname);
221221+ } catch {
222222+ // DNS resolution failed - fail closed for security
223223+ return { blocked: true, reason: `DNS resolution failed for ${hostname}` };
224224+ }
225225+ }
226226+227227+ // Validate ALL resolved IPs - block if ANY is private
228228+ for (const ip of addresses) {
229229+ const ipCheck = isPrivateIP(ip);
230230+ if (ipCheck.blocked) {
231231+ // Log detailed info server-side, return generic message to client
232232+ console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to ${ip} (${ipCheck.reason})`);
233233+ return { blocked: true, reason: 'Request blocked for security reasons' };
234234+ }
235235+236236+ // Also check cloud metadata IPs
237237+ if (ip === '169.254.169.254' || ip === '100.100.100.200') {
238238+ console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to cloud metadata IP ${ip}`);
239239+ return { blocked: true, reason: 'Request blocked for security reasons' };
240240+ }
241241+ }
242242+243243+ return { blocked: false };
244244+ } catch {
245245+ return { blocked: true, reason: 'Failed to validate URL' };
246246+ }
247247}
248248249249// Validate redirect location against SSRF blocklist
250250function validateRedirectLocation(location: string, baseUrl: string): string | null {
251251- try {
252252- // Resolve relative URLs against the base
253253- const resolved = new URL(location, baseUrl);
254254- const blockCheck = isBlockedUrl(resolved.href);
255255- if (blockCheck.blocked) {
256256- console.warn(`[cors-proxy] Blocked redirect to: ${resolved.href} - ${blockCheck.reason}`);
257257- return null;
258258- }
259259- return resolved.href;
260260- } catch {
261261- return null;
262262- }
251251+ try {
252252+ // Resolve relative URLs against the base
253253+ const resolved = new URL(location, baseUrl);
254254+ const blockCheck = isBlockedUrl(resolved.href);
255255+ if (blockCheck.blocked) {
256256+ console.warn(`[cors-proxy] Blocked redirect to: ${resolved.href} - ${blockCheck.reason}`);
257257+ return null;
258258+ }
259259+ return resolved.href;
260260+ } catch {
261261+ return null;
262262+ }
263263}
264264265265// Headers to skip when proxying request
266266const SKIP_REQUEST_HEADERS = new Set([
267267- 'host',
268268- 'connection',
269269- 'x-proxy-referer',
270270- 'x-proxy-cookie',
271271- 'x-proxy-user-agent',
267267+ 'host',
268268+ 'connection',
269269+ 'x-proxy-referer',
270270+ 'x-proxy-cookie',
271271+ 'x-proxy-user-agent',
272272]);
273273274274// Headers to skip when returning response
275275const SKIP_RESPONSE_HEADERS = new Set([
276276- 'transfer-encoding',
277277- 'content-encoding',
278278- 'content-length',
279279- // Frame-busting headers - we need to strip these for iframe embedding
280280- 'x-frame-options',
281281- 'content-security-policy',
282282- 'content-security-policy-report-only',
283283- // Other security headers that might interfere
284284- 'cross-origin-opener-policy',
285285- 'cross-origin-embedder-policy',
286286- 'cross-origin-resource-policy',
276276+ 'transfer-encoding',
277277+ 'content-encoding',
278278+ 'content-length',
279279+ // Frame-busting headers - we need to strip these for iframe embedding
280280+ 'x-frame-options',
281281+ 'content-security-policy',
282282+ 'content-security-policy-report-only',
283283+ // Other security headers that might interfere
284284+ 'cross-origin-opener-policy',
285285+ 'cross-origin-embedder-policy',
286286+ 'cross-origin-resource-policy',
287287]);
288288289289// Handle CORS preflight
290290app.options('/proxy/*', (c) => {
291291- const origin = c.req.header('Origin');
292292- const method = c.req.header('Access-Control-Request-Method');
293293- const headers = c.req.header('Access-Control-Request-Headers');
291291+ const origin = c.req.header('Origin');
292292+ const method = c.req.header('Access-Control-Request-Method');
293293+ const headers = c.req.header('Access-Control-Request-Headers');
294294295295- if (CORS_ALLOWED_ORIGINS.length && origin && !CORS_ALLOWED_ORIGINS.includes(origin)) {
296296- return c.json({ error: 'Origin not allowed' }, 403);
297297- }
295295+ if (CORS_ALLOWED_ORIGINS.length && origin && !CORS_ALLOWED_ORIGINS.includes(origin)) {
296296+ return c.json({ error: 'Origin not allowed' }, 403);
297297+ }
298298299299- if (origin && method && headers) {
300300- return new Response(null, {
301301- headers: {
302302- 'Access-Control-Allow-Methods': method,
303303- 'Access-Control-Allow-Headers': headers,
304304- 'Access-Control-Allow-Origin': origin,
305305- 'Access-Control-Allow-Credentials': 'true',
306306- 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
307307- },
308308- });
309309- }
299299+ if (origin && method && headers) {
300300+ return new Response(null, {
301301+ headers: {
302302+ 'Access-Control-Allow-Methods': method,
303303+ 'Access-Control-Allow-Headers': headers,
304304+ 'Access-Control-Allow-Origin': origin,
305305+ 'Access-Control-Allow-Credentials': 'true',
306306+ 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
307307+ },
308308+ });
309309+ }
310310311311- return new Response(null, {
312312- headers: {
313313- 'Allow': 'GET, HEAD, POST, OPTIONS',
314314- },
315315- });
311311+ return new Response(null, {
312312+ headers: {
313313+ 'Allow': 'GET, HEAD, POST, OPTIONS',
314314+ },
315315+ });
316316});
317317318318// Main proxy handler
319319app.all('/proxy/*', async (c) => {
320320- // Get client identifier for rate limiting (use origin or IP)
321321- const origin = c.req.header('Origin');
322322- const clientIp = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown';
323323- const clientId = origin || clientIp;
324324-325325- // Rate limiting check
326326- if (isRateLimited(clientId)) {
327327- console.warn(`[cors-proxy] Rate limited: ${clientId}`);
328328- return c.json({ error: 'Rate limit exceeded. Try again later.' }, 429);
329329- }
330330-331331- // CSRF Protection: Require Origin header for state-changing requests
332332- // GET/HEAD requests are safe from CSRF (no side effects) and may come from
333333- // service workers which don't always include Origin headers
334334- const requestMethod = c.req.method;
335335- const isReadOnly = requestMethod === 'GET' || requestMethod === 'HEAD';
336336-337337- if (!origin && !isReadOnly) {
338338- console.warn('[cors-proxy] Blocked state-changing request without Origin header');
339339- return c.json({ error: 'Origin header required' }, 403);
340340- }
341341-342342- if (origin && CORS_ALLOWED_ORIGINS.length && !CORS_ALLOWED_ORIGINS.includes(origin)) {
343343- console.warn(`[cors-proxy] Blocked request from unauthorized origin: ${origin}`);
344344- return c.json({ error: 'Origin not allowed' }, 403);
345345- }
320320+ // Get client identifier for rate limiting (use origin or IP)
321321+ const origin = c.req.header('Origin');
322322+ const clientIp = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown';
323323+ const clientId = origin || clientIp;
346324347347- // Extract the target URL from the path (needed for HMAC verification)
348348- let proxyUrl = c.req.path.slice('/proxy/'.length);
349349-350350- // Handle query string
351351- const queryString = new URL(c.req.url).search;
352352- if (queryString) {
353353- proxyUrl += queryString;
354354- }
325325+ // Rate limiting check
326326+ if (isRateLimited(clientId)) {
327327+ console.warn(`[cors-proxy] Rate limited: ${clientId}`);
328328+ return c.json({ error: 'Rate limit exceeded. Try again later.' }, 429);
329329+ }
355330356356- // Handle protocol-relative URLs
357357- if (proxyUrl.startsWith('//')) {
358358- proxyUrl = 'https:' + proxyUrl;
359359- }
331331+ // CSRF Protection: Require Origin header for state-changing requests
332332+ // GET/HEAD requests are safe from CSRF (no side effects) and may come from
333333+ // service workers which don't always include Origin headers
334334+ const requestMethod = c.req.method;
335335+ const isReadOnly = requestMethod === 'GET' || requestMethod === 'HEAD';
360336361361- // HMAC authentication check (when enabled)
362362- if (HMAC_ENABLED) {
363363- const timestamp = c.req.header('X-Seams-Timestamp');
364364- const signature = c.req.header('X-Seams-Signature');
365365-366366- if (!timestamp || !signature) {
367367- console.warn('[cors-proxy] HMAC auth failed: missing headers');
368368- return c.json({ error: 'Authentication required' }, 401);
369369- }
370370-371371- // Check timestamp freshness to prevent replay attacks
372372- const timestampMs = parseInt(timestamp, 10);
373373- const now = Date.now();
374374- if (isNaN(timestampMs) || Math.abs(now - timestampMs) > HMAC_MAX_AGE_MS) {
375375- console.warn(`[cors-proxy] HMAC auth failed: timestamp expired (drift: ${now - timestampMs}ms)`);
376376- return c.json({ error: 'Request expired' }, 401);
377377- }
378378-379379- // Verify signature
380380- if (!verifyHmacSignature(timestamp, proxyUrl, signature)) {
381381- console.warn('[cors-proxy] HMAC auth failed: invalid signature');
382382- return c.json({ error: 'Invalid signature' }, 401);
383383- }
384384- }
337337+ if (!origin && !isReadOnly) {
338338+ console.warn('[cors-proxy] Blocked state-changing request without Origin header');
339339+ return c.json({ error: 'Origin header required' }, 403);
340340+ }
385341386386- // Validate URL syntax
387387- try {
388388- new URL(proxyUrl);
389389- } catch {
390390- return c.json({ error: 'Invalid URL format' }, 400);
391391- }
342342+ if (origin && CORS_ALLOWED_ORIGINS.length && !CORS_ALLOWED_ORIGINS.includes(origin)) {
343343+ console.warn(`[cors-proxy] Blocked request from unauthorized origin: ${origin}`);
344344+ return c.json({ error: 'Origin not allowed' }, 403);
345345+ }
392346393393- // SSRF Protection: Block internal/private URLs (hostname check)
394394- const blockCheck = isBlockedUrl(proxyUrl);
395395- if (blockCheck.blocked) {
396396- console.warn(`[cors-proxy] SSRF blocked (hostname): ${proxyUrl} - ${blockCheck.reason}`);
397397- // Return generic error to avoid leaking internal network topology
398398- return c.json({ error: 'Request blocked' }, 403);
399399- }
347347+ // Extract the target URL from the path (needed for HMAC verification)
348348+ let proxyUrl = c.req.path.slice('/proxy/'.length);
400349401401- // SSRF Protection: Resolve DNS and validate resolved IPs (prevents DNS rebinding)
402402- const dnsCheck = await resolveAndValidate(proxyUrl);
403403- if (dnsCheck.blocked) {
404404- console.warn(`[cors-proxy] SSRF blocked (DNS): ${proxyUrl} - ${dnsCheck.reason}`);
405405- // Return generic error to avoid leaking DNS resolution details
406406- return c.json({ error: 'Request blocked' }, 403);
407407- }
350350+ // Handle query string
351351+ const queryString = new URL(c.req.url).search;
352352+ if (queryString) {
353353+ proxyUrl += queryString;
354354+ }
408355409409- // Audit log for successful proxy request start
410410- const requestId = Math.random().toString(36).substring(2, 10);
411411- console.log(`[cors-proxy] [${requestId}] Proxying: ${proxyUrl} (origin: ${origin})`);
356356+ // Handle protocol-relative URLs
357357+ if (proxyUrl.startsWith('//')) {
358358+ proxyUrl = 'https:' + proxyUrl;
359359+ }
412360413413- // Build proxy request headers
414414- const proxyHeaders = new Headers();
415415-416416- for (const [name, value] of c.req.raw.headers) {
417417- const lowerName = name.toLowerCase();
418418-419419- // Skip certain headers
420420- if (SKIP_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith('cf-') || lowerName.startsWith('x-pywb-')) {
421421- continue;
422422- }
423423-424424- proxyHeaders.set(name, value);
425425- }
361361+ // HMAC authentication check (when enabled)
362362+ if (HMAC_ENABLED) {
363363+ const timestamp = c.req.header('X-Seams-Timestamp');
364364+ const signature = c.req.header('X-Seams-Signature');
426365427427- // Handle referer
428428- const referrer = c.req.header('x-proxy-referer');
429429- if (referrer) {
430430- proxyHeaders.set('Referer', referrer);
431431- try {
432432- const refOrigin = new URL(referrer).origin;
433433- const targetOrigin = new URL(proxyUrl).origin;
434434- if (refOrigin !== targetOrigin) {
435435- proxyHeaders.set('Origin', refOrigin);
436436- proxyHeaders.set('Sec-Fetch-Site', 'cross-origin');
437437- } else {
438438- proxyHeaders.delete('Origin');
439439- proxyHeaders.set('Sec-Fetch-Site', 'same-origin');
440440- }
441441- } catch {
442442- // Ignore invalid referrer
443443- }
444444- } else {
445445- proxyHeaders.delete('Origin');
446446- proxyHeaders.delete('Referer');
447447- }
366366+ if (!timestamp || !signature) {
367367+ console.warn('[cors-proxy] HMAC auth failed: missing headers');
368368+ return c.json({ error: 'Authentication required' }, 401);
369369+ }
448370449449- // Handle custom user agent
450450- const ua = c.req.header('x-proxy-user-agent');
451451- if (ua) {
452452- proxyHeaders.set('User-Agent', ua);
453453- }
371371+ // Check timestamp freshness to prevent replay attacks
372372+ const timestampMs = parseInt(timestamp, 10);
373373+ const now = Date.now();
374374+ if (isNaN(timestampMs) || Math.abs(now - timestampMs) > HMAC_MAX_AGE_MS) {
375375+ console.warn(`[cors-proxy] HMAC auth failed: timestamp expired (drift: ${now - timestampMs}ms)`);
376376+ return c.json({ error: 'Request expired' }, 401);
377377+ }
454378455455- // Handle cookies
456456- const cookie = c.req.header('x-proxy-cookie');
457457- if (cookie) {
458458- proxyHeaders.set('Cookie', cookie);
459459- }
379379+ // Verify signature
380380+ if (!verifyHmacSignature(timestamp, proxyUrl, signature)) {
381381+ console.warn('[cors-proxy] HMAC auth failed: invalid signature');
382382+ return c.json({ error: 'Invalid signature' }, 401);
383383+ }
384384+ }
460385461461- // Get request body for non-GET/HEAD requests with size limit
462462- const method = c.req.method;
463463- let body: ReadableStream<Uint8Array> | null = null;
464464-465465- if (method !== 'GET' && method !== 'HEAD') {
466466- const contentLength = c.req.header('Content-Length');
467467- if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) {
468468- return c.json({ error: `Request body too large (max ${MAX_BODY_SIZE / 1024 / 1024}MB)` }, 413);
469469- }
470470- body = c.req.raw.body;
471471- }
386386+ // Validate URL syntax
387387+ try {
388388+ new URL(proxyUrl);
389389+ } catch {
390390+ return c.json({ error: 'Invalid URL format' }, 400);
391391+ }
472392473473- // Set up request timeout with AbortController
474474- const controller = new AbortController();
475475- const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
393393+ // SSRF Protection: Block internal/private URLs (hostname check)
394394+ const blockCheck = isBlockedUrl(proxyUrl);
395395+ if (blockCheck.blocked) {
396396+ console.warn(`[cors-proxy] SSRF blocked (hostname): ${proxyUrl} - ${blockCheck.reason}`);
397397+ // Return generic error to avoid leaking internal network topology
398398+ return c.json({ error: 'Request blocked' }, 403);
399399+ }
476400477477- try {
478478- // Fetch with redirect: manual to handle redirects specially
479479- const resp = await fetch(proxyUrl, {
480480- method,
481481- headers: proxyHeaders,
482482- body,
483483- redirect: 'manual',
484484- signal: controller.signal,
485485- });
486486-487487- // Clear timeout on successful fetch
488488- clearTimeout(timeoutId);
401401+ // SSRF Protection: Resolve DNS and validate resolved IPs (prevents DNS rebinding)
402402+ const dnsCheck = await resolveAndValidate(proxyUrl);
403403+ if (dnsCheck.blocked) {
404404+ console.warn(`[cors-proxy] SSRF blocked (DNS): ${proxyUrl} - ${dnsCheck.reason}`);
405405+ // Return generic error to avoid leaking DNS resolution details
406406+ return c.json({ error: 'Request blocked' }, 403);
407407+ }
489408490490- // Build response headers
491491- const responseHeaders = new Headers();
492492- const exposeHeaders: string[] = [
493493- 'x-redirect-status',
494494- 'x-redirect-statusText',
495495- 'x-proxy-set-cookie',
496496- 'x-orig-location',
497497- 'x-orig-ts',
498498- ];
409409+ // Audit log for successful proxy request start
410410+ const requestId = Math.random().toString(36).substring(2, 10);
411411+ console.log(`[cors-proxy] [${requestId}] Proxying: ${proxyUrl} (origin: ${origin})`);
499412500500- for (const [name, value] of resp.headers) {
501501- const lowerName = name.toLowerCase();
502502- if (!SKIP_RESPONSE_HEADERS.has(lowerName)) {
503503- responseHeaders.set(name, value);
504504- exposeHeaders.push(name);
505505- }
506506- }
413413+ // Build proxy request headers
414414+ const proxyHeaders = new Headers();
507415508508- // Handle set-cookie
509509- const setCookie = resp.headers.get('set-cookie');
510510- if (setCookie) {
511511- responseHeaders.set('X-Proxy-Set-Cookie', setCookie);
512512- }
416416+ for (const [name, value] of c.req.raw.headers) {
417417+ const lowerName = name.toLowerCase();
513418514514- // Handle redirects specially
515515- let status: number;
516516- const statusText = resp.statusText;
419419+ // Skip certain headers
420420+ if (SKIP_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith('cf-') || lowerName.startsWith('x-pywb-')) {
421421+ continue;
422422+ }
517423518518- if ([301, 302, 303, 307, 308].includes(resp.status)) {
519519- responseHeaders.set('x-redirect-status', String(resp.status));
520520- responseHeaders.set('x-redirect-statusText', resp.statusText);
521521-522522- const location = resp.headers.get('location');
523523- if (location) {
524524- // Validate redirect location against SSRF blocklist
525525- const validatedLocation = validateRedirectLocation(location, proxyUrl);
526526- if (validatedLocation) {
527527- responseHeaders.set('x-orig-location', validatedLocation);
528528- } else {
529529- // Don't expose blocked redirect locations
530530- responseHeaders.set('x-redirect-blocked', 'true');
531531- }
532532- }
533533-534534- // Return 200 so browser doesn't follow redirect
535535- status = 200;
536536- } else {
537537- status = resp.status;
538538- }
424424+ proxyHeaders.set(name, value);
425425+ }
426426+427427+ // Handle referer
428428+ const referrer = c.req.header('x-proxy-referer');
429429+ if (referrer) {
430430+ proxyHeaders.set('Referer', referrer);
431431+ try {
432432+ const refOrigin = new URL(referrer).origin;
433433+ const targetOrigin = new URL(proxyUrl).origin;
434434+ if (refOrigin !== targetOrigin) {
435435+ proxyHeaders.set('Origin', refOrigin);
436436+ proxyHeaders.set('Sec-Fetch-Site', 'cross-origin');
437437+ } else {
438438+ proxyHeaders.delete('Origin');
439439+ proxyHeaders.set('Sec-Fetch-Site', 'same-origin');
440440+ }
441441+ } catch {
442442+ // Ignore invalid referrer
443443+ }
444444+ } else {
445445+ proxyHeaders.delete('Origin');
446446+ proxyHeaders.delete('Referer');
447447+ }
448448+449449+ // Handle custom user agent
450450+ const ua = c.req.header('x-proxy-user-agent');
451451+ if (ua) {
452452+ proxyHeaders.set('User-Agent', ua);
453453+ }
454454+455455+ // Handle cookies
456456+ const cookie = c.req.header('x-proxy-cookie');
457457+ if (cookie) {
458458+ proxyHeaders.set('Cookie', cookie);
459459+ }
460460+461461+ // Get request body for non-GET/HEAD requests with size limit
462462+ const method = c.req.method;
463463+ let body: ReadableStream<Uint8Array> | null = null;
464464+465465+ if (method !== 'GET' && method !== 'HEAD') {
466466+ const contentLength = c.req.header('Content-Length');
467467+ if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) {
468468+ return c.json({ error: `Request body too large (max ${MAX_BODY_SIZE / 1024 / 1024}MB)` }, 413);
469469+ }
470470+ body = c.req.raw.body;
471471+ }
472472+473473+ // Set up request timeout with AbortController
474474+ const controller = new AbortController();
475475+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
476476+477477+ try {
478478+ // Fetch with redirect: manual to handle redirects specially
479479+ const resp = await fetch(proxyUrl, {
480480+ method,
481481+ headers: proxyHeaders,
482482+ body,
483483+ redirect: 'manual',
484484+ signal: controller.signal,
485485+ });
486486+487487+ // Clear timeout on successful fetch
488488+ clearTimeout(timeoutId);
489489+490490+ // Build response headers
491491+ const responseHeaders = new Headers();
492492+ const exposeHeaders: string[] = [
493493+ 'x-redirect-status',
494494+ 'x-redirect-statusText',
495495+ 'x-proxy-set-cookie',
496496+ 'x-orig-location',
497497+ 'x-orig-ts',
498498+ ];
499499+500500+ for (const [name, value] of resp.headers) {
501501+ const lowerName = name.toLowerCase();
502502+ if (!SKIP_RESPONSE_HEADERS.has(lowerName)) {
503503+ responseHeaders.set(name, value);
504504+ exposeHeaders.push(name);
505505+ }
506506+ }
507507+508508+ // Handle set-cookie
509509+ const setCookie = resp.headers.get('set-cookie');
510510+ if (setCookie) {
511511+ responseHeaders.set('X-Proxy-Set-Cookie', setCookie);
512512+ }
513513+514514+ // Handle redirects specially
515515+ let status: number;
516516+ const statusText = resp.statusText;
517517+518518+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
519519+ responseHeaders.set('x-redirect-status', String(resp.status));
520520+ responseHeaders.set('x-redirect-statusText', resp.statusText);
521521+522522+ const location = resp.headers.get('location');
523523+ if (location) {
524524+ // Validate redirect location against SSRF blocklist
525525+ const validatedLocation = validateRedirectLocation(location, proxyUrl);
526526+ if (validatedLocation) {
527527+ responseHeaders.set('x-orig-location', validatedLocation);
528528+ } else {
529529+ // Don't expose blocked redirect locations
530530+ responseHeaders.set('x-redirect-blocked', 'true');
531531+ }
532532+ }
533533+534534+ // Return 200 so browser doesn't follow redirect
535535+ status = 200;
536536+ } else {
537537+ status = resp.status;
538538+ }
539539+540540+ // Add CORS headers (only if Origin was provided)
541541+ if (origin) {
542542+ responseHeaders.set('Access-Control-Allow-Origin', origin);
543543+ responseHeaders.set('Access-Control-Allow-Credentials', 'true');
544544+ responseHeaders.set('Access-Control-Expose-Headers', [...exposeHeaders, 'X-Request-Id'].join(','));
545545+ }
546546+547547+ // Return request ID to client for debugging/correlation
548548+ responseHeaders.set('X-Request-Id', requestId);
549549+550550+ // Handle error status codes (>= 400, excluding 404 and memento responses)
551551+ let responseBody: ReadableStream<Uint8Array> | string | null;
552552+ if (status >= 400 && status !== 404 && !resp.headers.get('memento-datetime')) {
553553+ responseBody = `Sorry, this page could not be loaded (Error Status: ${status})`;
554554+ } else {
555555+ responseBody = resp.body;
556556+ }
539557540540- // Add CORS headers (only if Origin was provided)
541541- if (origin) {
542542- responseHeaders.set('Access-Control-Allow-Origin', origin);
543543- responseHeaders.set('Access-Control-Allow-Credentials', 'true');
544544- responseHeaders.set('Access-Control-Expose-Headers', [...exposeHeaders, 'X-Request-Id'].join(','));
545545- }
546546-547547- // Return request ID to client for debugging/correlation
548548- responseHeaders.set('X-Request-Id', requestId);
558558+ // Audit log for completed request
559559+ console.log(`[cors-proxy] [${requestId}] Completed: ${resp.status} ${resp.statusText}`);
549560550550- // Handle error status codes (>= 400, excluding 404 and memento responses)
551551- let responseBody: ReadableStream<Uint8Array> | string | null;
552552- if (status >= 400 && status !== 404 && !resp.headers.get('memento-datetime')) {
553553- responseBody = `Sorry, this page could not be loaded (Error Status: ${status})`;
554554- } else {
555555- responseBody = resp.body;
556556- }
561561+ return new Response(responseBody, {
562562+ headers: responseHeaders,
563563+ status,
564564+ statusText,
565565+ });
566566+ } catch (error) {
567567+ clearTimeout(timeoutId);
557568558558- // Audit log for completed request
559559- console.log(`[cors-proxy] [${requestId}] Completed: ${resp.status} ${resp.statusText}`);
569569+ if (error instanceof Error && error.name === 'AbortError') {
570570+ console.error(`[cors-proxy] [${requestId}] Timeout: ${proxyUrl}`);
571571+ return c.json({ error: 'Request timed out' }, 504);
572572+ }
560573561561- return new Response(responseBody, {
562562- headers: responseHeaders,
563563- status,
564564- statusText,
565565- });
566566- } catch (error) {
567567- clearTimeout(timeoutId);
568568-569569- if (error instanceof Error && error.name === 'AbortError') {
570570- console.error(`[cors-proxy] [${requestId}] Timeout: ${proxyUrl}`);
571571- return c.json({ error: 'Request timed out' }, 504);
572572- }
573573-574574- console.error(`[cors-proxy] [${requestId}] Fetch error:`, error);
575575- return c.json({ error: 'Failed to fetch target URL' }, 502);
576576- }
574574+ console.error(`[cors-proxy] [${requestId}] Fetch error:`, error);
575575+ return c.json({ error: 'Failed to fetch target URL' }, 502);
576576+ }
577577});
578578579579// Track server start time for uptime calculation
···581581582582// Health check with operational metrics
583583app.get('/', (c) => {
584584- return c.json({
585585- status: 'ok',
586586- service: 'seams-cors-proxy',
587587- uptime_seconds: Math.floor((Date.now() - serverStartTime) / 1000),
588588- rate_limit_clients: rateLimitMap.size,
589589- rate_limit_max_clients: RATE_LIMIT_MAX_CLIENTS,
590590- memory_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
591591- });
584584+ return c.json({
585585+ status: 'ok',
586586+ service: 'seams-cors-proxy',
587587+ uptime_seconds: Math.floor((Date.now() - serverStartTime) / 1000),
588588+ rate_limit_clients: rateLimitMap.size,
589589+ rate_limit_max_clients: RATE_LIMIT_MAX_CLIENTS,
590590+ memory_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
591591+ });
592592});
593593594594// Separate liveness probe (minimal, fast)
595595app.get('/healthz', (c) => {
596596- return c.text('ok');
596596+ return c.text('ok');
597597});
598598599599-const port = parseInt(process.env.CORS_PROXY_PORT || '8083', 10);
599599+const port = parseInt(process.env.CORS_PROXY_PORT || '8082', 10);
600600console.log(`[cors-proxy] Starting server on http://localhost:${port}`);
601601console.log(`[cors-proxy] Allowed origins: ${CORS_ALLOWED_ORIGINS.join(', ')}`);
602602console.log(`[cors-proxy] Rate limit: ${RATE_LIMIT_MAX_REQUESTS} req/${RATE_LIMIT_WINDOW_MS / 1000}s, max ${RATE_LIMIT_MAX_CLIENTS} clients`);
603603console.log(`[cors-proxy] Max body: ${MAX_BODY_SIZE / 1024 / 1024}MB, timeout: ${REQUEST_TIMEOUT_MS / 1000}s`);
604604console.log(`[cors-proxy] HMAC auth: ${HMAC_ENABLED ? 'ENABLED' : 'DISABLED (set CORS_PROXY_HMAC_SECRET to enable)'}`);
605605if (HMAC_ENABLED) {
606606- console.log(`[cors-proxy] HMAC max age: ${HMAC_MAX_AGE_MS / 1000}s`);
606606+ console.log(`[cors-proxy] HMAC max age: ${HMAC_MAX_AGE_MS / 1000}s`);
607607}
608608609609const server = serve({
610610- fetch: app.fetch,
611611- port,
610610+ fetch: app.fetch,
611611+ port,
612612});
613613614614// Graceful shutdown handling
615615let isShuttingDown = false;
616616617617function gracefulShutdown(signal: string) {
618618- if (isShuttingDown) return;
619619- isShuttingDown = true;
620620-621621- console.log(`[cors-proxy] Received ${signal}, shutting down gracefully...`);
622622-623623- // Give in-flight requests time to complete
624624- const SHUTDOWN_TIMEOUT = 10000;
625625- const shutdownTimer = setTimeout(() => {
626626- console.log('[cors-proxy] Shutdown timeout, forcing exit');
627627- process.exit(1);
628628- }, SHUTDOWN_TIMEOUT);
629629-630630- server.close(() => {
631631- clearTimeout(shutdownTimer);
632632- console.log('[cors-proxy] Server closed, exiting');
633633- process.exit(0);
634634- });
618618+ if (isShuttingDown) return;
619619+ isShuttingDown = true;
620620+621621+ console.log(`[cors-proxy] Received ${signal}, shutting down gracefully...`);
622622+623623+ // Give in-flight requests time to complete
624624+ const SHUTDOWN_TIMEOUT = 10000;
625625+ const shutdownTimer = setTimeout(() => {
626626+ console.log('[cors-proxy] Shutdown timeout, forcing exit');
627627+ process.exit(1);
628628+ }, SHUTDOWN_TIMEOUT);
629629+630630+ server.close(() => {
631631+ clearTimeout(shutdownTimer);
632632+ console.log('[cors-proxy] Server closed, exiting');
633633+ process.exit(0);
634634+ });
635635}
636636637637process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
+12-3
tests/helpers/extension.ts
···90909191/**
9292 * Waits for annotations to appear in the sidebar
9393+ * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root
9394 */
9495export async function waitForAnnotations(
9596 sidebarPage: Page,
9697 minCount: number = 1,
9798 timeout: number = 10000
9899): Promise<void> {
9999- await sidebarPage.waitForSelector('seams-annotation-card', {
100100+ // Playwright's waitForSelector pierces Shadow DOM automatically
101101+ await sidebarPage.waitForSelector('seams-sidebar seams-annotation-card', {
100102 timeout,
101103 state: 'attached',
102104 });
103105104104- // Wait for at least minCount annotations
106106+ // For waitForFunction, we need to manually traverse shadow roots
105107 await sidebarPage.waitForFunction(
106108 (count) => {
107107- const cards = document.querySelectorAll('seams-annotation-card');
109109+ const sidebarEl = document.querySelector('seams-sidebar');
110110+ if (!sidebarEl?.shadowRoot) return false;
111111+112112+ // Query inside the sidebar's shadow root
113113+ const container = sidebarEl.shadowRoot.querySelector('#sidebar-container');
114114+ if (!container) return false;
115115+116116+ const cards = container.querySelectorAll('seams-annotation-card');
108117 return cards.length >= count;
109118 },
110119 minCount,
+16-3
tests/helpers/proxy.ts
···8282}
83838484/**
8585- * Gets the sidebar container on the proxy page
8585+ * Gets the sidebar element on the proxy page
8686+ * The sidebar is a <seams-sidebar> web component with Shadow DOM
8687 */
8788export function getSidebar(page: Page) {
8888- return page.locator('.sidebar, #sidebar-container').first();
8989+ return page.locator('seams-sidebar');
8990}
90919192/**
9293 * Waits for annotations in the proxy sidebar
9494+ * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root
9395 */
9496export async function waitForProxyAnnotations(
9597 page: Page,
···98100): Promise<void> {
99101 const sidebar = getSidebar(page);
100102103103+ // Playwright's locator pierces Shadow DOM automatically
101104 await sidebar.locator('seams-annotation-card').first().waitFor({
102105 timeout,
103106 state: 'attached',
104107 });
105108109109+ // For waitForFunction, we need to manually traverse shadow roots
106110 await page.waitForFunction(
107111 (count) => {
108108- const cards = document.querySelectorAll('seams-annotation-card');
112112+ const sidebarEl = document.querySelector('seams-sidebar');
113113+ if (!sidebarEl?.shadowRoot) return false;
114114+115115+ // Query inside the sidebar's shadow root
116116+ const container = sidebarEl.shadowRoot.querySelector('#sidebar-container');
117117+ if (!container) return false;
118118+119119+ // seams-annotation-card is a nested web component with its own shadow root
120120+ // but the elements themselves are in the sidebar's shadow DOM
121121+ const cards = container.querySelectorAll('seams-annotation-card');
109122 return cards.length >= count;
110123 },
111124 minCount,