Social Annotations in the Atmosphere
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

web component sidebar

+825 -741
+25 -19
entrypoints/sidepanel/main.ts
··· 1 - import './style.css'; 2 - import { BrowserStorageAdapter, ExtensionOAuthLauncher, Sidebar } from '@seams/core'; 1 + import { BrowserStorageAdapter, ExtensionOAuthLauncher, SeamsSidebar, registerComponents } from '@seams/core'; 3 2 4 3 console.log('[seams.so] sidepanel script loading...'); 5 4 6 5 (function() { 6 + // Register web components 7 + registerComponents(); 8 + 7 9 const app = document.getElementById('app'); 8 10 if (!app) return; 9 11 10 12 const storage = new BrowserStorageAdapter(); 11 13 const launcher = new ExtensionOAuthLauncher(); 12 14 13 - const sidebar = new Sidebar( 14 - app, 15 - storage, 16 - launcher, 17 - { 18 - oauth: { 19 - clientId: import.meta.env.VITE_OAUTH_CLIENT_ID, 20 - redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 21 - scope: import.meta.env.VITE_OAUTH_SCOPE, 22 - }, 23 - pds: { 24 - backendUrl: import.meta.env.VITE_BACKEND_URL || import.meta.env.BACKEND_URL || 'https://seams.so', 25 - }, 15 + // Create the SeamsSidebar web component 16 + const sidebar = document.createElement('seams-sidebar') as SeamsSidebar; 17 + 18 + // Configure the component 19 + sidebar.storage = storage; 20 + sidebar.launcher = launcher; 21 + sidebar.config = { 22 + oauth: { 23 + clientId: import.meta.env.VITE_OAUTH_CLIENT_ID, 24 + redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 25 + scope: import.meta.env.VITE_OAUTH_SCOPE, 26 26 }, 27 - () => { 28 - browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 29 - } 30 - ); 27 + pds: { 28 + backendUrl: import.meta.env.VITE_BACKEND_URL || import.meta.env.BACKEND_URL || 'https://seams.so', 29 + }, 30 + }; 31 + sidebar.onSyncNeeded = () => { 32 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 33 + }; 34 + 35 + // Add to DOM - this triggers initialization 36 + app.appendChild(sidebar); 31 37 32 38 // Listen for messages from background/content 33 39 browser.runtime.onMessage.addListener((message) => {
+41 -19
entrypoints/sidepanel/style.css packages/core/src/components/sidebar-styles.ts
··· 1 + /** 2 + * Shadow DOM-compatible styles for the SeamsSidebar Web Component 3 + * 4 + * Converted from entrypoints/sidepanel/style.css with: 5 + * - :root -> :host (CSS variables) 6 + * - body -> :host (base styles) 7 + * - body::before -> :host::before (background pattern) 8 + * - All CSS variable references inlined for full encapsulation 9 + */ 10 + 11 + // Color constants (previously CSS variables) 12 + const FOREST_GREEN = '#2d5016'; 13 + const FOREST_GREEN_LIGHT = '#3d6b1f'; 14 + const FOREST_GREEN_DARK = '#1f3810'; 15 + 16 + export const SIDEBAR_STYLES = ` 17 + /* Reset - scoped to shadow root */ 1 18 * { 2 19 box-sizing: border-box; 3 20 margin: 0; 4 21 padding: 0; 5 22 } 6 23 7 - :root { 8 - --forest-green: #2d5016; 9 - --forest-green-light: #3d6b1f; 10 - --forest-green-dark: #1f3810; 11 - } 12 - 13 - body { 24 + /* Host element styles (replaces body) */ 25 + :host { 26 + display: block; 14 27 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 15 28 background: #fafafa; 16 29 color: #1a1a1a; 17 30 position: relative; 31 + height: 100%; 32 + overflow-y: auto; 18 33 } 19 34 20 - body::before { 35 + :host::before { 21 36 content: ''; 22 - position: fixed; 37 + position: absolute; 23 38 inset: 0; 24 39 pointer-events: none; 25 40 background-image: ··· 28 43 z-index: 0; 29 44 } 30 45 46 + /* Container inside shadow root - needs height for centering */ 47 + #sidebar-container { 48 + height: 100%; 49 + display: flex; 50 + flex-direction: column; 51 + } 52 + 31 53 .sidebar { 54 + flex: 1; 55 + display: flex; 56 + flex-direction: column; 32 57 padding: 16px; 33 58 max-width: 100%; 34 59 position: relative; ··· 42 67 } 43 68 44 69 button { 45 - background: var(--forest-green); 70 + background: ${FOREST_GREEN}; 46 71 color: white; 47 72 border: none; 48 73 padding: 8px 16px; ··· 52 77 } 53 78 54 79 button:hover { 55 - background: var(--forest-green-dark); 80 + background: ${FOREST_GREEN_DARK}; 56 81 } 57 82 58 83 .annotation-form { ··· 126 151 margin: 0 0 8px 0; 127 152 padding: 8px 12px; 128 153 background: #fafafa; 129 - border-left: 3px solid var(--forest-green); 154 + border-left: 3px solid ${FOREST_GREEN}; 130 155 font-style: italic; 131 156 color: #555; 132 157 } ··· 158 183 margin: 0; 159 184 padding: 8px 12px; 160 185 background: #f5f5f5; 161 - border-left: 3px solid var(--forest-green); 186 + border-left: 3px solid ${FOREST_GREEN}; 162 187 font-style: italic; 163 188 color: #333; 164 189 } ··· 173 198 flex-direction: column; 174 199 align-items: center; 175 200 justify-content: center; 176 - min-height: 100dvh; 201 + min-height: 100%; 177 202 padding: 32px 16px; 178 203 } 179 204 ··· 392 417 .reply-btn { 393 418 background: transparent; 394 419 border: none; 395 - color: var(--forest-green); 420 + color: ${FOREST_GREEN}; 396 421 font-size: 11px; 397 422 padding: 2px 4px; 398 423 cursor: pointer; ··· 452 477 background: #f5f5f5; 453 478 color: #333; 454 479 } 455 - 456 - .comment-thread { 457 - margin-top: 8px; 458 - } 480 + `;
+35 -56
entrypoints/via-client/shell.ts
··· 1 1 // Shell entry point - runs in the parent frame 2 - // Manages BackgroundWorker, storage, and renders Sidebar directly (no iframe) 2 + // Manages BackgroundWorker, storage, and renders SeamsSidebar web component 3 3 import { 4 4 WebStorageAdapter, 5 5 BackgroundWorker, 6 6 fetchAnnotations, 7 - Sidebar, 7 + SeamsSidebar, 8 8 DEFAULT_OAUTH_SCOPE, 9 9 WebOAuthLauncher, 10 + registerComponents, 10 11 } from '@seams/core'; 11 - 12 - // Import sidebar CSS - will be scoped to .sidebar-container 13 - import sidebarStyles from '../sidepanel/style.css?inline'; 14 12 15 13 console.log('[shell] Seams shell loading...'); 16 14 ··· 35 33 let contentFrame: HTMLIFrameElement | null = null; 36 34 37 35 // Sidebar instance (created after DOM ready) 38 - let sidebar: Sidebar | null = null; 36 + let sidebar: SeamsSidebar | null = null; 39 37 40 38 // Listen for storage changes and push to content iframe 41 39 storage.onChange((change) => { ··· 160 158 } 161 159 } 162 160 163 - // Inject scoped sidebar CSS 164 - function injectScopedStyles(): void { 165 - // Scope all CSS rules under .sidebar-container 166 - // This prevents the sidebar CSS from affecting the shell page 167 - const scopedStyles = sidebarStyles 168 - // Scope body rules to .sidebar-container 169 - .replace(/\bbody\s*\{/g, '.sidebar-container {') 170 - .replace(/\bbody::before\s*\{/g, '.sidebar-container::before {') 171 - // Scope universal selector - be more selective 172 - .replace(/^\*\s*\{/gm, '.sidebar-container * {') 173 - // Keep :root as-is (CSS variables) 174 - ; 161 + // Initialize sidebar using the SeamsSidebar web component 162 + function initSidebar(): void { 163 + // Register web components 164 + registerComponents(); 175 165 176 - const styleEl = document.createElement('style'); 177 - styleEl.textContent = scopedStyles; 178 - document.head.appendChild(styleEl); 179 - console.log('[shell] Injected scoped sidebar styles'); 180 - } 181 - 182 - // Initialize sidebar 183 - function initSidebar(): void { 184 166 const sidebarContainer = document.getElementById('sidebar-container'); 185 167 if (!sidebarContainer) { 186 168 console.error('[shell] Sidebar container not found'); 187 169 return; 188 170 } 189 171 190 - // Create the app div inside the container (Sidebar expects an element to render into) 191 - const appDiv = document.createElement('div'); 192 - appDiv.id = 'sidebar-app'; 193 - sidebarContainer.appendChild(appDiv); 172 + // Create the SeamsSidebar web component 173 + // It handles its own Shadow DOM and styles internally 174 + const sidebarEl = document.createElement('seams-sidebar') as SeamsSidebar; 194 175 195 - // Use PopupOAuthLauncher - popup flow works better than redirect for shell context 196 - const launcher = new WebOAuthLauncher(); 197 - 198 - sidebar = new Sidebar( 199 - appDiv, 200 - storage, 201 - launcher, 202 - { 203 - oauth: { 204 - // Note: These env vars are replaced at build time by vite.sure-client.config.ts 205 - // For development, VITE_OAUTH_CLIENT_ID uses the loopback format: http://localhost?redirect_uri=...&scope=... 206 - clientId: import.meta.env.VITE_OAUTH_CLIENT_ID, 207 - redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 208 - scope: import.meta.env.VITE_OAUTH_SCOPE || DEFAULT_OAUTH_SCOPE, 209 - }, 210 - pds: { 211 - backendUrl: BACKEND_URL, 212 - }, 176 + // Configure the component 177 + sidebarEl.storage = storage; 178 + sidebarEl.launcher = new WebOAuthLauncher(); 179 + sidebarEl.config = { 180 + oauth: { 181 + // Note: These env vars are replaced at build time by vite.sure-client.config.ts 182 + // For development, VITE_OAUTH_CLIENT_ID uses the loopback format: http://localhost?redirect_uri=...&scope=... 183 + clientId: import.meta.env.VITE_OAUTH_CLIENT_ID, 184 + redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 185 + scope: import.meta.env.VITE_OAUTH_SCOPE || DEFAULT_OAUTH_SCOPE, 213 186 }, 214 - () => { 215 - // Sync callback - directly call backgroundWorker 216 - console.log('[shell] Sidebar requested sync'); 217 - backgroundWorker.forceSync(); 218 - } 219 - ); 187 + pds: { 188 + backendUrl: BACKEND_URL, 189 + }, 190 + }; 191 + sidebarEl.onSyncNeeded = () => { 192 + // Sync callback - directly call backgroundWorker 193 + console.log('[shell] Sidebar requested sync'); 194 + backgroundWorker.forceSync(); 195 + }; 196 + 197 + // Add to DOM - this triggers connectedCallback and initialization 198 + sidebarContainer.appendChild(sidebarEl); 199 + sidebar = sidebarEl; 220 200 221 201 // If we already have a URL, set it on the sidebar 222 202 if (currentUrl) { ··· 237 217 console.error('[shell] Content iframe not found'); 238 218 } 239 219 240 - // Inject scoped CSS and initialize sidebar 241 - injectScopedStyles(); 220 + // Initialize sidebar (uses Shadow DOM for style encapsulation) 242 221 initSidebar(); 243 222 244 223 // Listen for messages from content iframe
-135
entrypoints/via-client/sidebar.css
··· 1 - * { 2 - margin: 0; 3 - padding: 0; 4 - box-sizing: border-box; 5 - } 6 - 7 - body { 8 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - color: #333; 12 - } 13 - 14 - .sidebar { 15 - display: flex; 16 - flex-direction: column; 17 - height: 100vh; 18 - background: white; 19 - } 20 - 21 - .sidebar-header { 22 - padding: 16px; 23 - border-bottom: 1px solid #e0e0e0; 24 - background: #f5f5f5; 25 - } 26 - 27 - .sidebar-header h1 { 28 - font-size: 20px; 29 - font-weight: 600; 30 - margin-bottom: 4px; 31 - } 32 - 33 - .sidebar-header p { 34 - font-size: 12px; 35 - color: #666; 36 - } 37 - 38 - .profile-info { 39 - display: flex; 40 - align-items: center; 41 - gap: 8px; 42 - margin-top: 8px; 43 - } 44 - 45 - .profile-avatar { 46 - width: 32px; 47 - height: 32px; 48 - border-radius: 50%; 49 - } 50 - 51 - .profile-handle { 52 - font-size: 14px; 53 - color: #333; 54 - } 55 - 56 - .sidebar-content { 57 - flex: 1; 58 - overflow-y: auto; 59 - padding: 16px; 60 - } 61 - 62 - .login-container { 63 - display: flex; 64 - flex-direction: column; 65 - gap: 12px; 66 - } 67 - 68 - .login-container h2 { 69 - font-size: 18px; 70 - font-weight: 600; 71 - margin-bottom: 8px; 72 - } 73 - 74 - .input-wrapper { 75 - position: relative; 76 - display: flex; 77 - align-items: center; 78 - } 79 - 80 - .at-symbol { 81 - position: absolute; 82 - left: 12px; 83 - color: #666; 84 - font-size: 14px; 85 - pointer-events: none; 86 - } 87 - 88 - .handle-input { 89 - width: 100%; 90 - padding: 10px 12px 10px 28px; 91 - border: 1px solid #ccc; 92 - border-radius: 4px; 93 - font-size: 14px; 94 - font-family: inherit; 95 - } 96 - 97 - .handle-input:focus { 98 - outline: none; 99 - border-color: #0085ff; 100 - } 101 - 102 - button { 103 - padding: 10px 16px; 104 - background: #0085ff; 105 - color: white; 106 - border: none; 107 - border-radius: 4px; 108 - font-size: 14px; 109 - font-weight: 500; 110 - cursor: pointer; 111 - font-family: inherit; 112 - } 113 - 114 - button:hover { 115 - background: #0073e6; 116 - } 117 - 118 - button:active { 119 - background: #0061c2; 120 - } 121 - 122 - #auth-status { 123 - font-size: 12px; 124 - color: #666; 125 - min-height: 16px; 126 - } 127 - 128 - #logout-btn { 129 - background: #e74c3c; 130 - margin-top: 8px; 131 - } 132 - 133 - #logout-btn:hover { 134 - background: #c0392b; 135 - }
+5
packages/core/src/components/index.ts
··· 1 1 import { SeamsAnnotationCard } from './annotation-card'; 2 + import { SeamsSidebar } from './sidebar'; 2 3 3 4 export * from './annotation-card'; 5 + export * from './sidebar'; 4 6 5 7 export function registerComponents() { 6 8 if (!customElements.get('seams-annotation-card')) { 7 9 customElements.define('seams-annotation-card', SeamsAnnotationCard); 10 + } 11 + if (!customElements.get('seams-sidebar')) { 12 + customElements.define('seams-sidebar', SeamsSidebar); 8 13 } 9 14 }
+178
packages/core/src/components/sidebar.ts
··· 1 + /** 2 + * SeamsSidebar Web Component 3 + * 4 + * A self-contained sidebar component with Shadow DOM encapsulation. 5 + * Used by both the browser extension sidepanel and the proxy client. 6 + * 7 + * Usage: 8 + * ```typescript 9 + * import { SeamsSidebar, registerComponents } from '@seams/core'; 10 + * 11 + * registerComponents(); 12 + * 13 + * const sidebar = document.createElement('seams-sidebar') as SeamsSidebar; 14 + * sidebar.storage = new BrowserStorageAdapter(); 15 + * sidebar.launcher = new ExtensionOAuthLauncher(); 16 + * sidebar.config = { oauth: {...}, pds: {...} }; 17 + * sidebar.onSyncNeeded = () => { ... }; 18 + * 19 + * document.body.appendChild(sidebar); 20 + * 21 + * // Control the sidebar 22 + * sidebar.setCurrentUrl('https://example.com'); 23 + * sidebar.setSelection({ text: '...', selectors: [...] }); 24 + * ``` 25 + */ 26 + 27 + import type { StorageAdapter } from '../storage'; 28 + import type { OAuthLauncher, OAuthConfig } from '../oauth'; 29 + import { Sidebar, type SidebarConfig, type SyncCallback } from '../sidebar'; 30 + import { SIDEBAR_STYLES } from './sidebar-styles'; 31 + 32 + export class SeamsSidebar extends HTMLElement { 33 + private _sidebar: Sidebar | null = null; 34 + private _initialized = false; 35 + 36 + // Configuration properties - must be set before connectedCallback 37 + private _storage: StorageAdapter | null = null; 38 + private _launcher: OAuthLauncher | null = null; 39 + private _config: SidebarConfig | null = null; 40 + private _onSyncNeeded: SyncCallback | undefined; 41 + 42 + constructor() { 43 + super(); 44 + this.attachShadow({ mode: 'open' }); 45 + } 46 + 47 + // Property setters for configuration 48 + set storage(value: StorageAdapter) { 49 + this._storage = value; 50 + this.tryInitialize(); 51 + } 52 + 53 + get storage(): StorageAdapter | null { 54 + return this._storage; 55 + } 56 + 57 + set launcher(value: OAuthLauncher) { 58 + this._launcher = value; 59 + this.tryInitialize(); 60 + } 61 + 62 + get launcher(): OAuthLauncher | null { 63 + return this._launcher; 64 + } 65 + 66 + set config(value: SidebarConfig) { 67 + this._config = value; 68 + this.tryInitialize(); 69 + } 70 + 71 + get config(): SidebarConfig | null { 72 + return this._config; 73 + } 74 + 75 + set onSyncNeeded(value: SyncCallback | undefined) { 76 + this._onSyncNeeded = value; 77 + // Note: Can't update this on existing Sidebar instance, but that's fine 78 + // since this is typically set before initialization 79 + } 80 + 81 + get onSyncNeeded(): SyncCallback | undefined { 82 + return this._onSyncNeeded; 83 + } 84 + 85 + connectedCallback() { 86 + this.tryInitialize(); 87 + } 88 + 89 + disconnectedCallback() { 90 + // Cleanup if needed 91 + this._sidebar = null; 92 + this._initialized = false; 93 + } 94 + 95 + /** 96 + * Try to initialize the sidebar if all required properties are set 97 + */ 98 + private tryInitialize() { 99 + // Don't initialize twice 100 + if (this._initialized) return; 101 + 102 + // Need all required properties 103 + if (!this._storage || !this._launcher || !this._config) return; 104 + 105 + // Need to be connected to DOM 106 + if (!this.isConnected) return; 107 + 108 + // Need shadow root 109 + if (!this.shadowRoot) return; 110 + 111 + this._initialized = true; 112 + this.render(); 113 + } 114 + 115 + private render() { 116 + if (!this.shadowRoot || !this._storage || !this._launcher || !this._config) { 117 + return; 118 + } 119 + 120 + // Inject styles 121 + const styleEl = document.createElement('style'); 122 + styleEl.textContent = SIDEBAR_STYLES; 123 + this.shadowRoot.appendChild(styleEl); 124 + 125 + // Create container for Sidebar class to render into 126 + const container = document.createElement('div'); 127 + container.id = 'sidebar-container'; 128 + this.shadowRoot.appendChild(container); 129 + 130 + // Initialize the Sidebar class 131 + this._sidebar = new Sidebar( 132 + container, 133 + this._storage, 134 + this._launcher, 135 + this._config, 136 + this._onSyncNeeded 137 + ); 138 + 139 + console.log('[seams-sidebar] Web component initialized'); 140 + } 141 + 142 + // Public API - delegates to internal Sidebar instance 143 + 144 + /** 145 + * Set the current page URL for annotation loading 146 + */ 147 + setCurrentUrl(url: string) { 148 + this._sidebar?.setCurrentUrl(url); 149 + } 150 + 151 + /** 152 + * Get the current URL 153 + */ 154 + getCurrentUrl(): string { 155 + return this._sidebar?.getCurrentUrl() || ''; 156 + } 157 + 158 + /** 159 + * Set the current text selection 160 + */ 161 + setSelection(selection: { text: string; selectors: any[] } | null) { 162 + this._sidebar?.setSelection(selection); 163 + } 164 + 165 + /** 166 + * Activate the annotation textarea (focus it) 167 + */ 168 + handleActivateAnnotation() { 169 + this._sidebar?.handleActivateAnnotation(); 170 + } 171 + 172 + /** 173 + * Programmatically create an annotation 174 + */ 175 + async createAnnotation(target: { source: string; selectors: any[] }, body: string) { 176 + return this._sidebar?.createAnnotation(target, body); 177 + } 178 + }
+11 -4
packages/core/src/sidebar/index.ts
··· 235 235 }); 236 236 237 237 document.addEventListener('click', (e) => { 238 - if (profileDropdown && profileAvatar && 239 - !profileAvatar.contains(e.target as Node) && 240 - !profileDropdown.contains(e.target as Node)) { 241 - profileDropdown.setAttribute('style', 'display: none;'); 238 + if (profileDropdown && profileAvatar) { 239 + // Use composedPath() for Shadow DOM compatibility 240 + // When events bubble out of a shadow root, e.target is retargeted to the shadow host 241 + // composedPath() gives us the full path including elements inside shadow roots 242 + const path = e.composedPath(); 243 + const clickedAvatar = path.includes(profileAvatar); 244 + const clickedDropdown = path.includes(profileDropdown); 245 + 246 + if (!clickedAvatar && !clickedDropdown) { 247 + profileDropdown.setAttribute('style', 'display: none;'); 248 + } 242 249 } 243 250 }); 244 251 }
+502 -502
sure-client-proxy/cors-proxy/index.ts
··· 17 17 * Headers: X-Seams-Timestamp (unix ms), X-Seams-Signature (base64) 18 18 */ 19 19 function verifyHmacSignature(timestamp: string, url: string, signature: string): boolean { 20 - if (!HMAC_SECRET) return false; 21 - 22 - const message = `${timestamp}:${url}`; 23 - const expectedSignature = crypto 24 - .createHmac('sha256', HMAC_SECRET) 25 - .update(message) 26 - .digest('base64'); 27 - 28 - // Use timing-safe comparison to prevent timing attacks 29 - try { 30 - return crypto.timingSafeEqual( 31 - Buffer.from(signature, 'base64'), 32 - Buffer.from(expectedSignature, 'base64') 33 - ); 34 - } catch { 35 - return false; 36 - } 20 + if (!HMAC_SECRET) return false; 21 + 22 + const message = `${timestamp}:${url}`; 23 + const expectedSignature = crypto 24 + .createHmac('sha256', HMAC_SECRET) 25 + .update(message) 26 + .digest('base64'); 27 + 28 + // Use timing-safe comparison to prevent timing attacks 29 + try { 30 + return crypto.timingSafeEqual( 31 + Buffer.from(signature, 'base64'), 32 + Buffer.from(expectedSignature, 'base64') 33 + ); 34 + } catch { 35 + return false; 36 + } 37 37 } 38 38 39 39 // Allowed origins for CORS (configurable via environment variable) 40 40 // Note: Use 127.0.0.1 for local dev (RFC 8252 requires loopback IP for OAuth) 41 41 const CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS 42 - ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim()) 43 - : [ 44 - 'http://127.0.0.1:8081', 45 - ]; 42 + ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim()) 43 + : [ 44 + 'http://127.0.0.1:8081', 45 + ]; 46 46 47 47 // Configurable limits via environment variables 48 48 const MAX_BODY_SIZE = parseInt(process.env.CORS_PROXY_MAX_BODY_SIZE || String(10 * 1024 * 1024), 10); ··· 57 57 const rateLimitMap = new Map<string, { count: number; windowStart: number }>(); 58 58 59 59 function isRateLimited(clientId: string): boolean { 60 - const now = Date.now(); 61 - const entry = rateLimitMap.get(clientId); 62 - 63 - if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { 64 - // Prevent memory exhaustion: if map is full, reject new clients 65 - if (rateLimitMap.size >= RATE_LIMIT_MAX_CLIENTS && !entry) { 66 - console.warn(`[cors-proxy] Rate limit map full (${RATE_LIMIT_MAX_CLIENTS} clients), rejecting new client`); 67 - return true; 68 - } 69 - rateLimitMap.set(clientId, { count: 1, windowStart: now }); 70 - return false; 71 - } 72 - 73 - entry.count++; 74 - if (entry.count > RATE_LIMIT_MAX_REQUESTS) { 75 - return true; 76 - } 77 - 78 - return false; 60 + const now = Date.now(); 61 + const entry = rateLimitMap.get(clientId); 62 + 63 + if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { 64 + // Prevent memory exhaustion: if map is full, reject new clients 65 + if (rateLimitMap.size >= RATE_LIMIT_MAX_CLIENTS && !entry) { 66 + console.warn(`[cors-proxy] Rate limit map full (${RATE_LIMIT_MAX_CLIENTS} clients), rejecting new client`); 67 + return true; 68 + } 69 + rateLimitMap.set(clientId, { count: 1, windowStart: now }); 70 + return false; 71 + } 72 + 73 + entry.count++; 74 + if (entry.count > RATE_LIMIT_MAX_REQUESTS) { 75 + return true; 76 + } 77 + 78 + return false; 79 79 } 80 80 81 81 // Clean up old rate limit entries periodically 82 82 setInterval(() => { 83 - const now = Date.now(); 84 - for (const [key, entry] of rateLimitMap.entries()) { 85 - if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { 86 - rateLimitMap.delete(key); 87 - } 88 - } 83 + const now = Date.now(); 84 + for (const [key, entry] of rateLimitMap.entries()) { 85 + if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { 86 + rateLimitMap.delete(key); 87 + } 88 + } 89 89 }, RATE_LIMIT_WINDOW_MS); 90 90 91 91 // Check if an IP address is private/internal 92 92 function isPrivateIP(ip: string): { blocked: boolean; reason?: string } { 93 - // Handle IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) 94 - const ipv4MappedMatch = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/i); 95 - if (ipv4MappedMatch) { 96 - const [, aStr, bStr] = ipv4MappedMatch; 97 - const a = Number(aStr); 98 - const b = Number(bStr); 99 - if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || 100 - (a === 169 && b === 254) || a === 127 || a === 0) { 101 - return { blocked: true, reason: 'Private IPv4-mapped IPv6 address' }; 102 - } 103 - } 104 - 105 - // IPv6 private ranges 106 - if (ip.match(/^f[cd][0-9a-f]{2}:/i)) { 107 - return { blocked: true, reason: 'IPv6 Unique Local Address (ULA)' }; 108 - } 109 - if (ip.match(/^fe[89ab][0-9a-f]:/i)) { 110 - return { blocked: true, reason: 'IPv6 link-local address' }; 111 - } 112 - if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') { 113 - return { blocked: true, reason: 'IPv6 loopback' }; 114 - } 115 - 116 - // IPv4 private ranges 117 - const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); 118 - if (ipv4Match) { 119 - const [, aStr, bStr] = ipv4Match; 120 - const a = Number(aStr); 121 - const b = Number(bStr); 122 - 123 - if (a === 10) { 124 - return { blocked: true, reason: 'Private IP (10.0.0.0/8)' }; 125 - } 126 - if (a === 172 && b >= 16 && b <= 31) { 127 - return { blocked: true, reason: 'Private IP (172.16.0.0/12)' }; 128 - } 129 - if (a === 192 && b === 168) { 130 - return { blocked: true, reason: 'Private IP (192.168.0.0/16)' }; 131 - } 132 - if (a === 169 && b === 254) { 133 - return { blocked: true, reason: 'Link-local IP (169.254.0.0/16)' }; 134 - } 135 - if (a === 127) { 136 - return { blocked: true, reason: 'Loopback IP (127.0.0.0/8)' }; 137 - } 138 - if (a === 0) { 139 - return { blocked: true, reason: 'Invalid IP (0.0.0.0/8)' }; 140 - } 141 - if (a === 100 && b >= 64 && b <= 127) { 142 - return { blocked: true, reason: 'Carrier-Grade NAT (100.64.0.0/10)' }; 143 - } 144 - } 145 - 146 - return { blocked: false }; 93 + // Handle IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) 94 + const ipv4MappedMatch = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/i); 95 + if (ipv4MappedMatch) { 96 + const [, aStr, bStr] = ipv4MappedMatch; 97 + const a = Number(aStr); 98 + const b = Number(bStr); 99 + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || 100 + (a === 169 && b === 254) || a === 127 || a === 0) { 101 + return { blocked: true, reason: 'Private IPv4-mapped IPv6 address' }; 102 + } 103 + } 104 + 105 + // IPv6 private ranges 106 + if (ip.match(/^f[cd][0-9a-f]{2}:/i)) { 107 + return { blocked: true, reason: 'IPv6 Unique Local Address (ULA)' }; 108 + } 109 + if (ip.match(/^fe[89ab][0-9a-f]:/i)) { 110 + return { blocked: true, reason: 'IPv6 link-local address' }; 111 + } 112 + if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') { 113 + return { blocked: true, reason: 'IPv6 loopback' }; 114 + } 115 + 116 + // IPv4 private ranges 117 + const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); 118 + if (ipv4Match) { 119 + const [, aStr, bStr] = ipv4Match; 120 + const a = Number(aStr); 121 + const b = Number(bStr); 122 + 123 + if (a === 10) { 124 + return { blocked: true, reason: 'Private IP (10.0.0.0/8)' }; 125 + } 126 + if (a === 172 && b >= 16 && b <= 31) { 127 + return { blocked: true, reason: 'Private IP (172.16.0.0/12)' }; 128 + } 129 + if (a === 192 && b === 168) { 130 + return { blocked: true, reason: 'Private IP (192.168.0.0/16)' }; 131 + } 132 + if (a === 169 && b === 254) { 133 + return { blocked: true, reason: 'Link-local IP (169.254.0.0/16)' }; 134 + } 135 + if (a === 127) { 136 + return { blocked: true, reason: 'Loopback IP (127.0.0.0/8)' }; 137 + } 138 + if (a === 0) { 139 + return { blocked: true, reason: 'Invalid IP (0.0.0.0/8)' }; 140 + } 141 + if (a === 100 && b >= 64 && b <= 127) { 142 + return { blocked: true, reason: 'Carrier-Grade NAT (100.64.0.0/10)' }; 143 + } 144 + } 145 + 146 + return { blocked: false }; 147 147 } 148 148 149 149 // SSRF Protection: Block private/internal IP ranges and cloud metadata endpoints 150 150 function isBlockedUrl(urlString: string): { blocked: boolean; reason?: string } { 151 - try { 152 - const url = new URL(urlString); 153 - let hostname = url.hostname.toLowerCase(); 154 - 155 - // Remove brackets from IPv6 addresses for validation 156 - if (hostname.startsWith('[') && hostname.endsWith(']')) { 157 - hostname = hostname.slice(1, -1); 158 - } 159 - 160 - // Block cloud metadata endpoints by hostname 161 - const metadataHosts = [ 162 - '169.254.169.254', // AWS/GCP/Azure metadata 163 - 'metadata.google.internal', 164 - 'metadata.google', 165 - '100.100.100.200', // Alibaba Cloud metadata 166 - 'fd00:ec2::254', // AWS IPv6 metadata 167 - ]; 168 - if (metadataHosts.includes(hostname)) { 169 - return { blocked: true, reason: 'Cloud metadata endpoint blocked' }; 170 - } 171 - 172 - // Block localhost variants 173 - if (hostname === 'localhost' || 174 - hostname === '127.0.0.1' || 175 - hostname === '::1' || 176 - hostname === '0.0.0.0' || 177 - hostname.endsWith('.localhost')) { 178 - return { blocked: true, reason: 'Localhost access blocked' }; 179 - } 180 - 181 - // Check if hostname is a direct IP address 182 - const ipCheck = isPrivateIP(hostname); 183 - if (ipCheck.blocked) { 184 - return ipCheck; 185 - } 186 - 187 - // Block file:// and other dangerous protocols 188 - if (url.protocol !== 'http:' && url.protocol !== 'https:') { 189 - return { blocked: true, reason: `Protocol ${url.protocol} not allowed` }; 190 - } 191 - 192 - return { blocked: false }; 193 - } catch { 194 - return { blocked: true, reason: 'Invalid URL format' }; 195 - } 151 + try { 152 + const url = new URL(urlString); 153 + let hostname = url.hostname.toLowerCase(); 154 + 155 + // Remove brackets from IPv6 addresses for validation 156 + if (hostname.startsWith('[') && hostname.endsWith(']')) { 157 + hostname = hostname.slice(1, -1); 158 + } 159 + 160 + // Block cloud metadata endpoints by hostname 161 + const metadataHosts = [ 162 + '169.254.169.254', // AWS/GCP/Azure metadata 163 + 'metadata.google.internal', 164 + 'metadata.google', 165 + '100.100.100.200', // Alibaba Cloud metadata 166 + 'fd00:ec2::254', // AWS IPv6 metadata 167 + ]; 168 + if (metadataHosts.includes(hostname)) { 169 + return { blocked: true, reason: 'Cloud metadata endpoint blocked' }; 170 + } 171 + 172 + // Block localhost variants 173 + if (hostname === 'localhost' || 174 + hostname === '127.0.0.1' || 175 + hostname === '::1' || 176 + hostname === '0.0.0.0' || 177 + hostname.endsWith('.localhost')) { 178 + return { blocked: true, reason: 'Localhost access blocked' }; 179 + } 180 + 181 + // Check if hostname is a direct IP address 182 + const ipCheck = isPrivateIP(hostname); 183 + if (ipCheck.blocked) { 184 + return ipCheck; 185 + } 186 + 187 + // Block file:// and other dangerous protocols 188 + if (url.protocol !== 'http:' && url.protocol !== 'https:') { 189 + return { blocked: true, reason: `Protocol ${url.protocol} not allowed` }; 190 + } 191 + 192 + return { blocked: false }; 193 + } catch { 194 + return { blocked: true, reason: 'Invalid URL format' }; 195 + } 196 196 } 197 197 198 198 // Resolve hostname and validate resolved IPs against SSRF blocklist ··· 201 201 // resolution. For full protection, deploy behind a network-level firewall that 202 202 // blocks outbound connections to private IP ranges. 203 203 async function resolveAndValidate(urlString: string): Promise<{ blocked: boolean; reason?: string }> { 204 - try { 205 - const url = new URL(urlString); 206 - const hostname = url.hostname; 207 - 208 - // Skip DNS resolution for IP addresses (already validated by isBlockedUrl) 209 - if (hostname.match(/^(\d+\.){3}\d+$/) || hostname.includes(':')) { 210 - return { blocked: false }; 211 - } 212 - 213 - // Resolve the hostname to IP addresses 214 - let addresses: string[]; 215 - try { 216 - addresses = await dns.resolve4(hostname); 217 - } catch { 218 - // If IPv4 fails, try IPv6 219 - try { 220 - addresses = await dns.resolve6(hostname); 221 - } catch { 222 - // DNS resolution failed - fail closed for security 223 - return { blocked: true, reason: `DNS resolution failed for ${hostname}` }; 224 - } 225 - } 226 - 227 - // Validate ALL resolved IPs - block if ANY is private 228 - for (const ip of addresses) { 229 - const ipCheck = isPrivateIP(ip); 230 - if (ipCheck.blocked) { 231 - // Log detailed info server-side, return generic message to client 232 - console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to ${ip} (${ipCheck.reason})`); 233 - return { blocked: true, reason: 'Request blocked for security reasons' }; 234 - } 235 - 236 - // Also check cloud metadata IPs 237 - if (ip === '169.254.169.254' || ip === '100.100.100.200') { 238 - console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to cloud metadata IP ${ip}`); 239 - return { blocked: true, reason: 'Request blocked for security reasons' }; 240 - } 241 - } 242 - 243 - return { blocked: false }; 244 - } catch { 245 - return { blocked: true, reason: 'Failed to validate URL' }; 246 - } 204 + try { 205 + const url = new URL(urlString); 206 + const hostname = url.hostname; 207 + 208 + // Skip DNS resolution for IP addresses (already validated by isBlockedUrl) 209 + if (hostname.match(/^(\d+\.){3}\d+$/) || hostname.includes(':')) { 210 + return { blocked: false }; 211 + } 212 + 213 + // Resolve the hostname to IP addresses 214 + let addresses: string[]; 215 + try { 216 + addresses = await dns.resolve4(hostname); 217 + } catch { 218 + // If IPv4 fails, try IPv6 219 + try { 220 + addresses = await dns.resolve6(hostname); 221 + } catch { 222 + // DNS resolution failed - fail closed for security 223 + return { blocked: true, reason: `DNS resolution failed for ${hostname}` }; 224 + } 225 + } 226 + 227 + // Validate ALL resolved IPs - block if ANY is private 228 + for (const ip of addresses) { 229 + const ipCheck = isPrivateIP(ip); 230 + if (ipCheck.blocked) { 231 + // Log detailed info server-side, return generic message to client 232 + console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to ${ip} (${ipCheck.reason})`); 233 + return { blocked: true, reason: 'Request blocked for security reasons' }; 234 + } 235 + 236 + // Also check cloud metadata IPs 237 + if (ip === '169.254.169.254' || ip === '100.100.100.200') { 238 + console.warn(`[cors-proxy] DNS rebinding blocked: ${hostname} resolves to cloud metadata IP ${ip}`); 239 + return { blocked: true, reason: 'Request blocked for security reasons' }; 240 + } 241 + } 242 + 243 + return { blocked: false }; 244 + } catch { 245 + return { blocked: true, reason: 'Failed to validate URL' }; 246 + } 247 247 } 248 248 249 249 // Validate redirect location against SSRF blocklist 250 250 function validateRedirectLocation(location: string, baseUrl: string): string | null { 251 - try { 252 - // Resolve relative URLs against the base 253 - const resolved = new URL(location, baseUrl); 254 - const blockCheck = isBlockedUrl(resolved.href); 255 - if (blockCheck.blocked) { 256 - console.warn(`[cors-proxy] Blocked redirect to: ${resolved.href} - ${blockCheck.reason}`); 257 - return null; 258 - } 259 - return resolved.href; 260 - } catch { 261 - return null; 262 - } 251 + try { 252 + // Resolve relative URLs against the base 253 + const resolved = new URL(location, baseUrl); 254 + const blockCheck = isBlockedUrl(resolved.href); 255 + if (blockCheck.blocked) { 256 + console.warn(`[cors-proxy] Blocked redirect to: ${resolved.href} - ${blockCheck.reason}`); 257 + return null; 258 + } 259 + return resolved.href; 260 + } catch { 261 + return null; 262 + } 263 263 } 264 264 265 265 // Headers to skip when proxying request 266 266 const SKIP_REQUEST_HEADERS = new Set([ 267 - 'host', 268 - 'connection', 269 - 'x-proxy-referer', 270 - 'x-proxy-cookie', 271 - 'x-proxy-user-agent', 267 + 'host', 268 + 'connection', 269 + 'x-proxy-referer', 270 + 'x-proxy-cookie', 271 + 'x-proxy-user-agent', 272 272 ]); 273 273 274 274 // Headers to skip when returning response 275 275 const SKIP_RESPONSE_HEADERS = new Set([ 276 - 'transfer-encoding', 277 - 'content-encoding', 278 - 'content-length', 279 - // Frame-busting headers - we need to strip these for iframe embedding 280 - 'x-frame-options', 281 - 'content-security-policy', 282 - 'content-security-policy-report-only', 283 - // Other security headers that might interfere 284 - 'cross-origin-opener-policy', 285 - 'cross-origin-embedder-policy', 286 - 'cross-origin-resource-policy', 276 + 'transfer-encoding', 277 + 'content-encoding', 278 + 'content-length', 279 + // Frame-busting headers - we need to strip these for iframe embedding 280 + 'x-frame-options', 281 + 'content-security-policy', 282 + 'content-security-policy-report-only', 283 + // Other security headers that might interfere 284 + 'cross-origin-opener-policy', 285 + 'cross-origin-embedder-policy', 286 + 'cross-origin-resource-policy', 287 287 ]); 288 288 289 289 // Handle CORS preflight 290 290 app.options('/proxy/*', (c) => { 291 - const origin = c.req.header('Origin'); 292 - const method = c.req.header('Access-Control-Request-Method'); 293 - const headers = c.req.header('Access-Control-Request-Headers'); 291 + const origin = c.req.header('Origin'); 292 + const method = c.req.header('Access-Control-Request-Method'); 293 + const headers = c.req.header('Access-Control-Request-Headers'); 294 294 295 - if (CORS_ALLOWED_ORIGINS.length && origin && !CORS_ALLOWED_ORIGINS.includes(origin)) { 296 - return c.json({ error: 'Origin not allowed' }, 403); 297 - } 295 + if (CORS_ALLOWED_ORIGINS.length && origin && !CORS_ALLOWED_ORIGINS.includes(origin)) { 296 + return c.json({ error: 'Origin not allowed' }, 403); 297 + } 298 298 299 - if (origin && method && headers) { 300 - return new Response(null, { 301 - headers: { 302 - 'Access-Control-Allow-Methods': method, 303 - 'Access-Control-Allow-Headers': headers, 304 - 'Access-Control-Allow-Origin': origin, 305 - 'Access-Control-Allow-Credentials': 'true', 306 - 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours 307 - }, 308 - }); 309 - } 299 + if (origin && method && headers) { 300 + return new Response(null, { 301 + headers: { 302 + 'Access-Control-Allow-Methods': method, 303 + 'Access-Control-Allow-Headers': headers, 304 + 'Access-Control-Allow-Origin': origin, 305 + 'Access-Control-Allow-Credentials': 'true', 306 + 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours 307 + }, 308 + }); 309 + } 310 310 311 - return new Response(null, { 312 - headers: { 313 - 'Allow': 'GET, HEAD, POST, OPTIONS', 314 - }, 315 - }); 311 + return new Response(null, { 312 + headers: { 313 + 'Allow': 'GET, HEAD, POST, OPTIONS', 314 + }, 315 + }); 316 316 }); 317 317 318 318 // Main proxy handler 319 319 app.all('/proxy/*', async (c) => { 320 - // Get client identifier for rate limiting (use origin or IP) 321 - const origin = c.req.header('Origin'); 322 - const clientIp = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown'; 323 - const clientId = origin || clientIp; 324 - 325 - // Rate limiting check 326 - if (isRateLimited(clientId)) { 327 - console.warn(`[cors-proxy] Rate limited: ${clientId}`); 328 - return c.json({ error: 'Rate limit exceeded. Try again later.' }, 429); 329 - } 330 - 331 - // CSRF Protection: Require Origin header for state-changing requests 332 - // GET/HEAD requests are safe from CSRF (no side effects) and may come from 333 - // service workers which don't always include Origin headers 334 - const requestMethod = c.req.method; 335 - const isReadOnly = requestMethod === 'GET' || requestMethod === 'HEAD'; 336 - 337 - if (!origin && !isReadOnly) { 338 - console.warn('[cors-proxy] Blocked state-changing request without Origin header'); 339 - return c.json({ error: 'Origin header required' }, 403); 340 - } 341 - 342 - if (origin && CORS_ALLOWED_ORIGINS.length && !CORS_ALLOWED_ORIGINS.includes(origin)) { 343 - console.warn(`[cors-proxy] Blocked request from unauthorized origin: ${origin}`); 344 - return c.json({ error: 'Origin not allowed' }, 403); 345 - } 320 + // Get client identifier for rate limiting (use origin or IP) 321 + const origin = c.req.header('Origin'); 322 + const clientIp = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown'; 323 + const clientId = origin || clientIp; 346 324 347 - // Extract the target URL from the path (needed for HMAC verification) 348 - let proxyUrl = c.req.path.slice('/proxy/'.length); 349 - 350 - // Handle query string 351 - const queryString = new URL(c.req.url).search; 352 - if (queryString) { 353 - proxyUrl += queryString; 354 - } 325 + // Rate limiting check 326 + if (isRateLimited(clientId)) { 327 + console.warn(`[cors-proxy] Rate limited: ${clientId}`); 328 + return c.json({ error: 'Rate limit exceeded. Try again later.' }, 429); 329 + } 355 330 356 - // Handle protocol-relative URLs 357 - if (proxyUrl.startsWith('//')) { 358 - proxyUrl = 'https:' + proxyUrl; 359 - } 331 + // CSRF Protection: Require Origin header for state-changing requests 332 + // GET/HEAD requests are safe from CSRF (no side effects) and may come from 333 + // service workers which don't always include Origin headers 334 + const requestMethod = c.req.method; 335 + const isReadOnly = requestMethod === 'GET' || requestMethod === 'HEAD'; 360 336 361 - // HMAC authentication check (when enabled) 362 - if (HMAC_ENABLED) { 363 - const timestamp = c.req.header('X-Seams-Timestamp'); 364 - const signature = c.req.header('X-Seams-Signature'); 365 - 366 - if (!timestamp || !signature) { 367 - console.warn('[cors-proxy] HMAC auth failed: missing headers'); 368 - return c.json({ error: 'Authentication required' }, 401); 369 - } 370 - 371 - // Check timestamp freshness to prevent replay attacks 372 - const timestampMs = parseInt(timestamp, 10); 373 - const now = Date.now(); 374 - if (isNaN(timestampMs) || Math.abs(now - timestampMs) > HMAC_MAX_AGE_MS) { 375 - console.warn(`[cors-proxy] HMAC auth failed: timestamp expired (drift: ${now - timestampMs}ms)`); 376 - return c.json({ error: 'Request expired' }, 401); 377 - } 378 - 379 - // Verify signature 380 - if (!verifyHmacSignature(timestamp, proxyUrl, signature)) { 381 - console.warn('[cors-proxy] HMAC auth failed: invalid signature'); 382 - return c.json({ error: 'Invalid signature' }, 401); 383 - } 384 - } 337 + if (!origin && !isReadOnly) { 338 + console.warn('[cors-proxy] Blocked state-changing request without Origin header'); 339 + return c.json({ error: 'Origin header required' }, 403); 340 + } 385 341 386 - // Validate URL syntax 387 - try { 388 - new URL(proxyUrl); 389 - } catch { 390 - return c.json({ error: 'Invalid URL format' }, 400); 391 - } 342 + if (origin && CORS_ALLOWED_ORIGINS.length && !CORS_ALLOWED_ORIGINS.includes(origin)) { 343 + console.warn(`[cors-proxy] Blocked request from unauthorized origin: ${origin}`); 344 + return c.json({ error: 'Origin not allowed' }, 403); 345 + } 392 346 393 - // SSRF Protection: Block internal/private URLs (hostname check) 394 - const blockCheck = isBlockedUrl(proxyUrl); 395 - if (blockCheck.blocked) { 396 - console.warn(`[cors-proxy] SSRF blocked (hostname): ${proxyUrl} - ${blockCheck.reason}`); 397 - // Return generic error to avoid leaking internal network topology 398 - return c.json({ error: 'Request blocked' }, 403); 399 - } 347 + // Extract the target URL from the path (needed for HMAC verification) 348 + let proxyUrl = c.req.path.slice('/proxy/'.length); 400 349 401 - // SSRF Protection: Resolve DNS and validate resolved IPs (prevents DNS rebinding) 402 - const dnsCheck = await resolveAndValidate(proxyUrl); 403 - if (dnsCheck.blocked) { 404 - console.warn(`[cors-proxy] SSRF blocked (DNS): ${proxyUrl} - ${dnsCheck.reason}`); 405 - // Return generic error to avoid leaking DNS resolution details 406 - return c.json({ error: 'Request blocked' }, 403); 407 - } 350 + // Handle query string 351 + const queryString = new URL(c.req.url).search; 352 + if (queryString) { 353 + proxyUrl += queryString; 354 + } 408 355 409 - // Audit log for successful proxy request start 410 - const requestId = Math.random().toString(36).substring(2, 10); 411 - console.log(`[cors-proxy] [${requestId}] Proxying: ${proxyUrl} (origin: ${origin})`); 356 + // Handle protocol-relative URLs 357 + if (proxyUrl.startsWith('//')) { 358 + proxyUrl = 'https:' + proxyUrl; 359 + } 412 360 413 - // Build proxy request headers 414 - const proxyHeaders = new Headers(); 415 - 416 - for (const [name, value] of c.req.raw.headers) { 417 - const lowerName = name.toLowerCase(); 418 - 419 - // Skip certain headers 420 - if (SKIP_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith('cf-') || lowerName.startsWith('x-pywb-')) { 421 - continue; 422 - } 423 - 424 - proxyHeaders.set(name, value); 425 - } 361 + // HMAC authentication check (when enabled) 362 + if (HMAC_ENABLED) { 363 + const timestamp = c.req.header('X-Seams-Timestamp'); 364 + const signature = c.req.header('X-Seams-Signature'); 426 365 427 - // Handle referer 428 - const referrer = c.req.header('x-proxy-referer'); 429 - if (referrer) { 430 - proxyHeaders.set('Referer', referrer); 431 - try { 432 - const refOrigin = new URL(referrer).origin; 433 - const targetOrigin = new URL(proxyUrl).origin; 434 - if (refOrigin !== targetOrigin) { 435 - proxyHeaders.set('Origin', refOrigin); 436 - proxyHeaders.set('Sec-Fetch-Site', 'cross-origin'); 437 - } else { 438 - proxyHeaders.delete('Origin'); 439 - proxyHeaders.set('Sec-Fetch-Site', 'same-origin'); 440 - } 441 - } catch { 442 - // Ignore invalid referrer 443 - } 444 - } else { 445 - proxyHeaders.delete('Origin'); 446 - proxyHeaders.delete('Referer'); 447 - } 366 + if (!timestamp || !signature) { 367 + console.warn('[cors-proxy] HMAC auth failed: missing headers'); 368 + return c.json({ error: 'Authentication required' }, 401); 369 + } 448 370 449 - // Handle custom user agent 450 - const ua = c.req.header('x-proxy-user-agent'); 451 - if (ua) { 452 - proxyHeaders.set('User-Agent', ua); 453 - } 371 + // Check timestamp freshness to prevent replay attacks 372 + const timestampMs = parseInt(timestamp, 10); 373 + const now = Date.now(); 374 + if (isNaN(timestampMs) || Math.abs(now - timestampMs) > HMAC_MAX_AGE_MS) { 375 + console.warn(`[cors-proxy] HMAC auth failed: timestamp expired (drift: ${now - timestampMs}ms)`); 376 + return c.json({ error: 'Request expired' }, 401); 377 + } 454 378 455 - // Handle cookies 456 - const cookie = c.req.header('x-proxy-cookie'); 457 - if (cookie) { 458 - proxyHeaders.set('Cookie', cookie); 459 - } 379 + // Verify signature 380 + if (!verifyHmacSignature(timestamp, proxyUrl, signature)) { 381 + console.warn('[cors-proxy] HMAC auth failed: invalid signature'); 382 + return c.json({ error: 'Invalid signature' }, 401); 383 + } 384 + } 460 385 461 - // Get request body for non-GET/HEAD requests with size limit 462 - const method = c.req.method; 463 - let body: ReadableStream<Uint8Array> | null = null; 464 - 465 - if (method !== 'GET' && method !== 'HEAD') { 466 - const contentLength = c.req.header('Content-Length'); 467 - if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { 468 - return c.json({ error: `Request body too large (max ${MAX_BODY_SIZE / 1024 / 1024}MB)` }, 413); 469 - } 470 - body = c.req.raw.body; 471 - } 386 + // Validate URL syntax 387 + try { 388 + new URL(proxyUrl); 389 + } catch { 390 + return c.json({ error: 'Invalid URL format' }, 400); 391 + } 472 392 473 - // Set up request timeout with AbortController 474 - const controller = new AbortController(); 475 - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); 393 + // SSRF Protection: Block internal/private URLs (hostname check) 394 + const blockCheck = isBlockedUrl(proxyUrl); 395 + if (blockCheck.blocked) { 396 + console.warn(`[cors-proxy] SSRF blocked (hostname): ${proxyUrl} - ${blockCheck.reason}`); 397 + // Return generic error to avoid leaking internal network topology 398 + return c.json({ error: 'Request blocked' }, 403); 399 + } 476 400 477 - try { 478 - // Fetch with redirect: manual to handle redirects specially 479 - const resp = await fetch(proxyUrl, { 480 - method, 481 - headers: proxyHeaders, 482 - body, 483 - redirect: 'manual', 484 - signal: controller.signal, 485 - }); 486 - 487 - // Clear timeout on successful fetch 488 - clearTimeout(timeoutId); 401 + // SSRF Protection: Resolve DNS and validate resolved IPs (prevents DNS rebinding) 402 + const dnsCheck = await resolveAndValidate(proxyUrl); 403 + if (dnsCheck.blocked) { 404 + console.warn(`[cors-proxy] SSRF blocked (DNS): ${proxyUrl} - ${dnsCheck.reason}`); 405 + // Return generic error to avoid leaking DNS resolution details 406 + return c.json({ error: 'Request blocked' }, 403); 407 + } 489 408 490 - // Build response headers 491 - const responseHeaders = new Headers(); 492 - const exposeHeaders: string[] = [ 493 - 'x-redirect-status', 494 - 'x-redirect-statusText', 495 - 'x-proxy-set-cookie', 496 - 'x-orig-location', 497 - 'x-orig-ts', 498 - ]; 409 + // Audit log for successful proxy request start 410 + const requestId = Math.random().toString(36).substring(2, 10); 411 + console.log(`[cors-proxy] [${requestId}] Proxying: ${proxyUrl} (origin: ${origin})`); 499 412 500 - for (const [name, value] of resp.headers) { 501 - const lowerName = name.toLowerCase(); 502 - if (!SKIP_RESPONSE_HEADERS.has(lowerName)) { 503 - responseHeaders.set(name, value); 504 - exposeHeaders.push(name); 505 - } 506 - } 413 + // Build proxy request headers 414 + const proxyHeaders = new Headers(); 507 415 508 - // Handle set-cookie 509 - const setCookie = resp.headers.get('set-cookie'); 510 - if (setCookie) { 511 - responseHeaders.set('X-Proxy-Set-Cookie', setCookie); 512 - } 416 + for (const [name, value] of c.req.raw.headers) { 417 + const lowerName = name.toLowerCase(); 513 418 514 - // Handle redirects specially 515 - let status: number; 516 - const statusText = resp.statusText; 419 + // Skip certain headers 420 + if (SKIP_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith('cf-') || lowerName.startsWith('x-pywb-')) { 421 + continue; 422 + } 517 423 518 - if ([301, 302, 303, 307, 308].includes(resp.status)) { 519 - responseHeaders.set('x-redirect-status', String(resp.status)); 520 - responseHeaders.set('x-redirect-statusText', resp.statusText); 521 - 522 - const location = resp.headers.get('location'); 523 - if (location) { 524 - // Validate redirect location against SSRF blocklist 525 - const validatedLocation = validateRedirectLocation(location, proxyUrl); 526 - if (validatedLocation) { 527 - responseHeaders.set('x-orig-location', validatedLocation); 528 - } else { 529 - // Don't expose blocked redirect locations 530 - responseHeaders.set('x-redirect-blocked', 'true'); 531 - } 532 - } 533 - 534 - // Return 200 so browser doesn't follow redirect 535 - status = 200; 536 - } else { 537 - status = resp.status; 538 - } 424 + proxyHeaders.set(name, value); 425 + } 426 + 427 + // Handle referer 428 + const referrer = c.req.header('x-proxy-referer'); 429 + if (referrer) { 430 + proxyHeaders.set('Referer', referrer); 431 + try { 432 + const refOrigin = new URL(referrer).origin; 433 + const targetOrigin = new URL(proxyUrl).origin; 434 + if (refOrigin !== targetOrigin) { 435 + proxyHeaders.set('Origin', refOrigin); 436 + proxyHeaders.set('Sec-Fetch-Site', 'cross-origin'); 437 + } else { 438 + proxyHeaders.delete('Origin'); 439 + proxyHeaders.set('Sec-Fetch-Site', 'same-origin'); 440 + } 441 + } catch { 442 + // Ignore invalid referrer 443 + } 444 + } else { 445 + proxyHeaders.delete('Origin'); 446 + proxyHeaders.delete('Referer'); 447 + } 448 + 449 + // Handle custom user agent 450 + const ua = c.req.header('x-proxy-user-agent'); 451 + if (ua) { 452 + proxyHeaders.set('User-Agent', ua); 453 + } 454 + 455 + // Handle cookies 456 + const cookie = c.req.header('x-proxy-cookie'); 457 + if (cookie) { 458 + proxyHeaders.set('Cookie', cookie); 459 + } 460 + 461 + // Get request body for non-GET/HEAD requests with size limit 462 + const method = c.req.method; 463 + let body: ReadableStream<Uint8Array> | null = null; 464 + 465 + if (method !== 'GET' && method !== 'HEAD') { 466 + const contentLength = c.req.header('Content-Length'); 467 + if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { 468 + return c.json({ error: `Request body too large (max ${MAX_BODY_SIZE / 1024 / 1024}MB)` }, 413); 469 + } 470 + body = c.req.raw.body; 471 + } 472 + 473 + // Set up request timeout with AbortController 474 + const controller = new AbortController(); 475 + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); 476 + 477 + try { 478 + // Fetch with redirect: manual to handle redirects specially 479 + const resp = await fetch(proxyUrl, { 480 + method, 481 + headers: proxyHeaders, 482 + body, 483 + redirect: 'manual', 484 + signal: controller.signal, 485 + }); 486 + 487 + // Clear timeout on successful fetch 488 + clearTimeout(timeoutId); 489 + 490 + // Build response headers 491 + const responseHeaders = new Headers(); 492 + const exposeHeaders: string[] = [ 493 + 'x-redirect-status', 494 + 'x-redirect-statusText', 495 + 'x-proxy-set-cookie', 496 + 'x-orig-location', 497 + 'x-orig-ts', 498 + ]; 499 + 500 + for (const [name, value] of resp.headers) { 501 + const lowerName = name.toLowerCase(); 502 + if (!SKIP_RESPONSE_HEADERS.has(lowerName)) { 503 + responseHeaders.set(name, value); 504 + exposeHeaders.push(name); 505 + } 506 + } 507 + 508 + // Handle set-cookie 509 + const setCookie = resp.headers.get('set-cookie'); 510 + if (setCookie) { 511 + responseHeaders.set('X-Proxy-Set-Cookie', setCookie); 512 + } 513 + 514 + // Handle redirects specially 515 + let status: number; 516 + const statusText = resp.statusText; 517 + 518 + if ([301, 302, 303, 307, 308].includes(resp.status)) { 519 + responseHeaders.set('x-redirect-status', String(resp.status)); 520 + responseHeaders.set('x-redirect-statusText', resp.statusText); 521 + 522 + const location = resp.headers.get('location'); 523 + if (location) { 524 + // Validate redirect location against SSRF blocklist 525 + const validatedLocation = validateRedirectLocation(location, proxyUrl); 526 + if (validatedLocation) { 527 + responseHeaders.set('x-orig-location', validatedLocation); 528 + } else { 529 + // Don't expose blocked redirect locations 530 + responseHeaders.set('x-redirect-blocked', 'true'); 531 + } 532 + } 533 + 534 + // Return 200 so browser doesn't follow redirect 535 + status = 200; 536 + } else { 537 + status = resp.status; 538 + } 539 + 540 + // Add CORS headers (only if Origin was provided) 541 + if (origin) { 542 + responseHeaders.set('Access-Control-Allow-Origin', origin); 543 + responseHeaders.set('Access-Control-Allow-Credentials', 'true'); 544 + responseHeaders.set('Access-Control-Expose-Headers', [...exposeHeaders, 'X-Request-Id'].join(',')); 545 + } 546 + 547 + // Return request ID to client for debugging/correlation 548 + responseHeaders.set('X-Request-Id', requestId); 549 + 550 + // Handle error status codes (>= 400, excluding 404 and memento responses) 551 + let responseBody: ReadableStream<Uint8Array> | string | null; 552 + if (status >= 400 && status !== 404 && !resp.headers.get('memento-datetime')) { 553 + responseBody = `Sorry, this page could not be loaded (Error Status: ${status})`; 554 + } else { 555 + responseBody = resp.body; 556 + } 539 557 540 - // Add CORS headers (only if Origin was provided) 541 - if (origin) { 542 - responseHeaders.set('Access-Control-Allow-Origin', origin); 543 - responseHeaders.set('Access-Control-Allow-Credentials', 'true'); 544 - responseHeaders.set('Access-Control-Expose-Headers', [...exposeHeaders, 'X-Request-Id'].join(',')); 545 - } 546 - 547 - // Return request ID to client for debugging/correlation 548 - responseHeaders.set('X-Request-Id', requestId); 558 + // Audit log for completed request 559 + console.log(`[cors-proxy] [${requestId}] Completed: ${resp.status} ${resp.statusText}`); 549 560 550 - // Handle error status codes (>= 400, excluding 404 and memento responses) 551 - let responseBody: ReadableStream<Uint8Array> | string | null; 552 - if (status >= 400 && status !== 404 && !resp.headers.get('memento-datetime')) { 553 - responseBody = `Sorry, this page could not be loaded (Error Status: ${status})`; 554 - } else { 555 - responseBody = resp.body; 556 - } 561 + return new Response(responseBody, { 562 + headers: responseHeaders, 563 + status, 564 + statusText, 565 + }); 566 + } catch (error) { 567 + clearTimeout(timeoutId); 557 568 558 - // Audit log for completed request 559 - console.log(`[cors-proxy] [${requestId}] Completed: ${resp.status} ${resp.statusText}`); 569 + if (error instanceof Error && error.name === 'AbortError') { 570 + console.error(`[cors-proxy] [${requestId}] Timeout: ${proxyUrl}`); 571 + return c.json({ error: 'Request timed out' }, 504); 572 + } 560 573 561 - return new Response(responseBody, { 562 - headers: responseHeaders, 563 - status, 564 - statusText, 565 - }); 566 - } catch (error) { 567 - clearTimeout(timeoutId); 568 - 569 - if (error instanceof Error && error.name === 'AbortError') { 570 - console.error(`[cors-proxy] [${requestId}] Timeout: ${proxyUrl}`); 571 - return c.json({ error: 'Request timed out' }, 504); 572 - } 573 - 574 - console.error(`[cors-proxy] [${requestId}] Fetch error:`, error); 575 - return c.json({ error: 'Failed to fetch target URL' }, 502); 576 - } 574 + console.error(`[cors-proxy] [${requestId}] Fetch error:`, error); 575 + return c.json({ error: 'Failed to fetch target URL' }, 502); 576 + } 577 577 }); 578 578 579 579 // Track server start time for uptime calculation ··· 581 581 582 582 // Health check with operational metrics 583 583 app.get('/', (c) => { 584 - return c.json({ 585 - status: 'ok', 586 - service: 'seams-cors-proxy', 587 - uptime_seconds: Math.floor((Date.now() - serverStartTime) / 1000), 588 - rate_limit_clients: rateLimitMap.size, 589 - rate_limit_max_clients: RATE_LIMIT_MAX_CLIENTS, 590 - memory_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), 591 - }); 584 + return c.json({ 585 + status: 'ok', 586 + service: 'seams-cors-proxy', 587 + uptime_seconds: Math.floor((Date.now() - serverStartTime) / 1000), 588 + rate_limit_clients: rateLimitMap.size, 589 + rate_limit_max_clients: RATE_LIMIT_MAX_CLIENTS, 590 + memory_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), 591 + }); 592 592 }); 593 593 594 594 // Separate liveness probe (minimal, fast) 595 595 app.get('/healthz', (c) => { 596 - return c.text('ok'); 596 + return c.text('ok'); 597 597 }); 598 598 599 - const port = parseInt(process.env.CORS_PROXY_PORT || '8083', 10); 599 + const port = parseInt(process.env.CORS_PROXY_PORT || '8082', 10); 600 600 console.log(`[cors-proxy] Starting server on http://localhost:${port}`); 601 601 console.log(`[cors-proxy] Allowed origins: ${CORS_ALLOWED_ORIGINS.join(', ')}`); 602 602 console.log(`[cors-proxy] Rate limit: ${RATE_LIMIT_MAX_REQUESTS} req/${RATE_LIMIT_WINDOW_MS / 1000}s, max ${RATE_LIMIT_MAX_CLIENTS} clients`); 603 603 console.log(`[cors-proxy] Max body: ${MAX_BODY_SIZE / 1024 / 1024}MB, timeout: ${REQUEST_TIMEOUT_MS / 1000}s`); 604 604 console.log(`[cors-proxy] HMAC auth: ${HMAC_ENABLED ? 'ENABLED' : 'DISABLED (set CORS_PROXY_HMAC_SECRET to enable)'}`); 605 605 if (HMAC_ENABLED) { 606 - console.log(`[cors-proxy] HMAC max age: ${HMAC_MAX_AGE_MS / 1000}s`); 606 + console.log(`[cors-proxy] HMAC max age: ${HMAC_MAX_AGE_MS / 1000}s`); 607 607 } 608 608 609 609 const server = serve({ 610 - fetch: app.fetch, 611 - port, 610 + fetch: app.fetch, 611 + port, 612 612 }); 613 613 614 614 // Graceful shutdown handling 615 615 let isShuttingDown = false; 616 616 617 617 function gracefulShutdown(signal: string) { 618 - if (isShuttingDown) return; 619 - isShuttingDown = true; 620 - 621 - console.log(`[cors-proxy] Received ${signal}, shutting down gracefully...`); 622 - 623 - // Give in-flight requests time to complete 624 - const SHUTDOWN_TIMEOUT = 10000; 625 - const shutdownTimer = setTimeout(() => { 626 - console.log('[cors-proxy] Shutdown timeout, forcing exit'); 627 - process.exit(1); 628 - }, SHUTDOWN_TIMEOUT); 629 - 630 - server.close(() => { 631 - clearTimeout(shutdownTimer); 632 - console.log('[cors-proxy] Server closed, exiting'); 633 - process.exit(0); 634 - }); 618 + if (isShuttingDown) return; 619 + isShuttingDown = true; 620 + 621 + console.log(`[cors-proxy] Received ${signal}, shutting down gracefully...`); 622 + 623 + // Give in-flight requests time to complete 624 + const SHUTDOWN_TIMEOUT = 10000; 625 + const shutdownTimer = setTimeout(() => { 626 + console.log('[cors-proxy] Shutdown timeout, forcing exit'); 627 + process.exit(1); 628 + }, SHUTDOWN_TIMEOUT); 629 + 630 + server.close(() => { 631 + clearTimeout(shutdownTimer); 632 + console.log('[cors-proxy] Server closed, exiting'); 633 + process.exit(0); 634 + }); 635 635 } 636 636 637 637 process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
+12 -3
tests/helpers/extension.ts
··· 90 90 91 91 /** 92 92 * Waits for annotations to appear in the sidebar 93 + * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root 93 94 */ 94 95 export async function waitForAnnotations( 95 96 sidebarPage: Page, 96 97 minCount: number = 1, 97 98 timeout: number = 10000 98 99 ): Promise<void> { 99 - await sidebarPage.waitForSelector('seams-annotation-card', { 100 + // Playwright's waitForSelector pierces Shadow DOM automatically 101 + await sidebarPage.waitForSelector('seams-sidebar seams-annotation-card', { 100 102 timeout, 101 103 state: 'attached', 102 104 }); 103 105 104 - // Wait for at least minCount annotations 106 + // For waitForFunction, we need to manually traverse shadow roots 105 107 await sidebarPage.waitForFunction( 106 108 (count) => { 107 - const cards = document.querySelectorAll('seams-annotation-card'); 109 + const sidebarEl = document.querySelector('seams-sidebar'); 110 + if (!sidebarEl?.shadowRoot) return false; 111 + 112 + // Query inside the sidebar's shadow root 113 + const container = sidebarEl.shadowRoot.querySelector('#sidebar-container'); 114 + if (!container) return false; 115 + 116 + const cards = container.querySelectorAll('seams-annotation-card'); 108 117 return cards.length >= count; 109 118 }, 110 119 minCount,
+16 -3
tests/helpers/proxy.ts
··· 82 82 } 83 83 84 84 /** 85 - * Gets the sidebar container on the proxy page 85 + * Gets the sidebar element on the proxy page 86 + * The sidebar is a <seams-sidebar> web component with Shadow DOM 86 87 */ 87 88 export function getSidebar(page: Page) { 88 - return page.locator('.sidebar, #sidebar-container').first(); 89 + return page.locator('seams-sidebar'); 89 90 } 90 91 91 92 /** 92 93 * Waits for annotations in the proxy sidebar 94 + * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root 93 95 */ 94 96 export async function waitForProxyAnnotations( 95 97 page: Page, ··· 98 100 ): Promise<void> { 99 101 const sidebar = getSidebar(page); 100 102 103 + // Playwright's locator pierces Shadow DOM automatically 101 104 await sidebar.locator('seams-annotation-card').first().waitFor({ 102 105 timeout, 103 106 state: 'attached', 104 107 }); 105 108 109 + // For waitForFunction, we need to manually traverse shadow roots 106 110 await page.waitForFunction( 107 111 (count) => { 108 - const cards = document.querySelectorAll('seams-annotation-card'); 112 + const sidebarEl = document.querySelector('seams-sidebar'); 113 + if (!sidebarEl?.shadowRoot) return false; 114 + 115 + // Query inside the sidebar's shadow root 116 + const container = sidebarEl.shadowRoot.querySelector('#sidebar-container'); 117 + if (!container) return false; 118 + 119 + // seams-annotation-card is a nested web component with its own shadow root 120 + // but the elements themselves are in the sidebar's shadow DOM 121 + const cards = container.querySelectorAll('seams-annotation-card'); 109 122 return cards.length >= count; 110 123 }, 111 124 minCount,