Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

better csrf handling

Changed files
+169 -11
hosting-service
src
src
+14 -6
hosting-service/src/server.ts
··· 119 119 } 120 120 121 121 // Fetch and cache the site 122 - const record = await fetchSiteRecord(did, rkey); 123 - if (!record) { 122 + const siteData = await fetchSiteRecord(did, rkey); 123 + if (!siteData) { 124 124 console.error('Site record not found', did, rkey); 125 125 return false; 126 126 } ··· 132 132 } 133 133 134 134 try { 135 - await downloadAndCacheSite(did, rkey, record, pdsEndpoint); 135 + await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 136 136 return true; 137 137 } catch (err) { 138 138 console.error('Failed to cache site', did, rkey, err); ··· 153 153 154 154 // Check if this is sites.wisp.place subdomain 155 155 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 156 - // Extract identifier and site from path: /did:plc:123abc/sitename/file.html 157 - const pathParts = rawPath.split('/'); 156 + // Sanitize the path FIRST to prevent path traversal 157 + const sanitizedFullPath = sanitizePath(rawPath); 158 + 159 + // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 160 + const pathParts = sanitizedFullPath.split('/'); 158 161 if (pathParts.length < 2) { 159 162 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 160 163 } 161 164 162 165 const identifier = pathParts[0]; 163 166 const site = pathParts[1]; 164 - const filePath = sanitizePath(pathParts.slice(2).join('/')); 167 + const filePath = pathParts.slice(2).join('/'); 165 168 166 169 console.log('[Sites] Serving', { identifier, site, filePath }); 170 + 171 + // Additional validation: identifier must be a valid DID or handle format 172 + if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 173 + return c.text('Invalid identifier', 400); 174 + } 167 175 168 176 // Validate site name (rkey) 169 177 if (!isValidRkey(site)) {
+5 -2
src/index.ts
··· 16 16 import { wispRoutes } from './routes/wisp' 17 17 import { domainRoutes } from './routes/domain' 18 18 import { userRoutes } from './routes/user' 19 + import { csrfProtection } from './lib/csrf' 19 20 20 21 const config: Config = { 21 22 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, ··· 74 75 prefix: '/' 75 76 }) 76 77 ) 78 + .use(csrfProtection()) 77 79 .use(authRoutes(client)) 78 80 .use(wispRoutes(client)) 79 81 .use(domainRoutes(client)) ··· 96 98 .use(cors({ 97 99 origin: config.domain, 98 100 credentials: true, 99 - methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 100 - allowedHeaders: ['Content-Type', 'Authorization'], 101 + methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], 102 + allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], 103 + exposeHeaders: ['Content-Type'], 101 104 maxAge: 86400 // 24 hours 102 105 })) 103 106 .listen(8000)
+80
src/lib/csrf.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { logger } from './logger' 3 + 4 + /** 5 + * CSRF Protection using Origin/Host header verification 6 + * Based on Lucia's recommended approach for cookie-based authentication 7 + * 8 + * This validates that the Origin header matches the Host header for 9 + * state-changing requests (POST, PUT, DELETE, PATCH). 10 + */ 11 + 12 + /** 13 + * Verify that the request origin matches the expected host 14 + * @param origin - The Origin header value 15 + * @param allowedHosts - Array of allowed host values 16 + * @returns true if origin is valid, false otherwise 17 + */ 18 + export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean { 19 + if (!origin) { 20 + return false 21 + } 22 + 23 + try { 24 + const originUrl = new URL(origin) 25 + const originHost = originUrl.host 26 + 27 + return allowedHosts.some(host => originHost === host) 28 + } catch { 29 + // Invalid URL 30 + return false 31 + } 32 + } 33 + 34 + /** 35 + * CSRF Protection Middleware for Elysia 36 + * 37 + * Validates Origin header against Host header for non-GET requests 38 + * to prevent CSRF attacks when using cookie-based authentication. 39 + * 40 + * Usage: 41 + * ```ts 42 + * import { csrfProtection } from './lib/csrf' 43 + * 44 + * new Elysia() 45 + * .use(csrfProtection()) 46 + * .post('/api/protected', handler) 47 + * ``` 48 + */ 49 + export const csrfProtection = () => { 50 + return new Elysia({ name: 'csrf-protection' }) 51 + .onBeforeHandle(({ request, set }) => { 52 + const method = request.method.toUpperCase() 53 + 54 + // Only protect state-changing methods 55 + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { 56 + return 57 + } 58 + 59 + // Get headers 60 + const originHeader = request.headers.get('Origin') 61 + // Use X-Forwarded-Host if behind a proxy, otherwise use Host 62 + const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host') 63 + 64 + // Validate origin matches host 65 + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { 66 + logger.warn('[CSRF] Request blocked', { 67 + method, 68 + origin: originHeader, 69 + host: hostHeader, 70 + path: new URL(request.url).pathname 71 + }) 72 + 73 + set.status = 403 74 + return { 75 + error: 'CSRF validation failed', 76 + message: 'Request origin does not match host' 77 + } 78 + } 79 + }) 80 + }
+9
src/lib/logger.ts
··· 14 14 } 15 15 }, 16 16 17 + // Warning logging (always logged but may be sanitized in production) 18 + warn: (message: string, context?: Record<string, any>) => { 19 + if (isDev) { 20 + console.warn(message, context); 21 + } else { 22 + console.warn(message); 23 + } 24 + }, 25 + 17 26 // Safe error logging - sanitizes in production 18 27 error: (message: string, error?: any) => { 19 28 if (isDev) {
+61 -3
src/routes/domain.ts
··· 170 170 const { domain } = body as { domain: string }; 171 171 const domainLower = domain.toLowerCase().trim(); 172 172 173 - // Basic validation 174 - if (!domainLower || domainLower.length < 3) { 175 - throw new Error('Invalid domain'); 173 + // Enhanced domain validation 174 + // 1. Length check (RFC 1035: labels 1-63 chars, total max 253) 175 + if (!domainLower || domainLower.length < 3 || domainLower.length > 253) { 176 + throw new Error('Invalid domain: must be 3-253 characters'); 177 + } 178 + 179 + // 2. Basic format validation 180 + // - Must contain at least one dot (require TLD) 181 + // - Valid characters: a-z, 0-9, hyphen, dot 182 + // - No consecutive dots, no leading/trailing dots or hyphens 183 + const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; 184 + if (!domainPattern.test(domainLower)) { 185 + throw new Error('Invalid domain format'); 186 + } 187 + 188 + // 3. Validate each label (part between dots) 189 + const labels = domainLower.split('.'); 190 + for (const label of labels) { 191 + if (label.length === 0 || label.length > 63) { 192 + throw new Error('Invalid domain: label length must be 1-63 characters'); 193 + } 194 + if (label.startsWith('-') || label.endsWith('-')) { 195 + throw new Error('Invalid domain: labels cannot start or end with hyphen'); 196 + } 197 + } 198 + 199 + // 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs) 200 + const tld = labels[labels.length - 1]; 201 + if (tld.length < 2 || /^\d+$/.test(tld)) { 202 + throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric'); 203 + } 204 + 205 + // 5. Homograph attack protection - block domains with mixed scripts or confusables 206 + // Block non-ASCII characters (Punycode domains should be pre-converted) 207 + if (!/^[a-z0-9.-]+$/.test(domainLower)) { 208 + throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed'); 209 + } 210 + 211 + // 6. Block localhost, internal IPs, and reserved domains 212 + const blockedDomains = [ 213 + 'localhost', 214 + 'example.com', 215 + 'example.org', 216 + 'example.net', 217 + 'test', 218 + 'invalid', 219 + 'local' 220 + ]; 221 + const blockedPatterns = [ 222 + /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs 223 + /^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address 224 + ]; 225 + 226 + if (blockedDomains.includes(domainLower)) { 227 + throw new Error('Invalid domain: reserved or blocked domain'); 228 + } 229 + 230 + for (const pattern of blockedPatterns) { 231 + if (pattern.test(domainLower)) { 232 + throw new Error('Invalid domain: IP addresses not allowed'); 233 + } 176 234 } 177 235 178 236 // Check if already exists