From e4db0e0b5077cb95f95dfceed5512435b5db37bb Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Sat, 25 Oct 2025 16:07:00 -0400 Subject: [PATCH] better csrf handling --- hosting-service/src/server.ts | 20 ++++++--- src/index.ts | 7 ++- src/lib/csrf.ts | 80 +++++++++++++++++++++++++++++++++++ src/lib/logger.ts | 9 ++++ src/routes/domain.ts | 64 ++++++++++++++++++++++++++-- 5 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 src/lib/csrf.ts diff --git a/hosting-service/src/server.ts b/hosting-service/src/server.ts index 5030cea..bf66b58 100644 --- a/hosting-service/src/server.ts +++ b/hosting-service/src/server.ts @@ -119,8 +119,8 @@ async function ensureSiteCached(did: string, rkey: string): Promise { } // Fetch and cache the site - const record = await fetchSiteRecord(did, rkey); - if (!record) { + const siteData = await fetchSiteRecord(did, rkey); + if (!siteData) { console.error('Site record not found', did, rkey); return false; } @@ -132,7 +132,7 @@ async function ensureSiteCached(did: string, rkey: string): Promise { } try { - await downloadAndCacheSite(did, rkey, record, pdsEndpoint); + await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); return true; } catch (err) { console.error('Failed to cache site', did, rkey, err); @@ -153,18 +153,26 @@ app.get('/*', async (c) => { // Check if this is sites.wisp.place subdomain if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { - // Extract identifier and site from path: /did:plc:123abc/sitename/file.html - const pathParts = rawPath.split('/'); + // Sanitize the path FIRST to prevent path traversal + const sanitizedFullPath = sanitizePath(rawPath); + + // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html + const pathParts = sanitizedFullPath.split('/'); if (pathParts.length < 2) { return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); } const identifier = pathParts[0]; const site = pathParts[1]; - const filePath = sanitizePath(pathParts.slice(2).join('/')); + const filePath = pathParts.slice(2).join('/'); console.log('[Sites] Serving', { identifier, site, filePath }); + // Additional validation: identifier must be a valid DID or handle format + if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { + return c.text('Invalid identifier', 400); + } + // Validate site name (rkey) if (!isValidRkey(site)) { return c.text('Invalid site name', 400); diff --git a/src/index.ts b/src/index.ts index 8284289..d4f9a4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { authRoutes } from './routes/auth' import { wispRoutes } from './routes/wisp' import { domainRoutes } from './routes/domain' import { userRoutes } from './routes/user' +import { csrfProtection } from './lib/csrf' const config: Config = { domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, @@ -74,6 +75,7 @@ export const app = new Elysia() prefix: '/' }) ) + .use(csrfProtection()) .use(authRoutes(client)) .use(wispRoutes(client)) .use(domainRoutes(client)) @@ -96,8 +98,9 @@ export const app = new Elysia() .use(cors({ origin: config.domain, credentials: true, - methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], + exposeHeaders: ['Content-Type'], maxAge: 86400 // 24 hours })) .listen(8000) diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts new file mode 100644 index 0000000..b6e1172 --- /dev/null +++ b/src/lib/csrf.ts @@ -0,0 +1,80 @@ +import { Elysia } from 'elysia' +import { logger } from './logger' + +/** + * CSRF Protection using Origin/Host header verification + * Based on Lucia's recommended approach for cookie-based authentication + * + * This validates that the Origin header matches the Host header for + * state-changing requests (POST, PUT, DELETE, PATCH). + */ + +/** + * Verify that the request origin matches the expected host + * @param origin - The Origin header value + * @param allowedHosts - Array of allowed host values + * @returns true if origin is valid, false otherwise + */ +export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean { + if (!origin) { + return false + } + + try { + const originUrl = new URL(origin) + const originHost = originUrl.host + + return allowedHosts.some(host => originHost === host) + } catch { + // Invalid URL + return false + } +} + +/** + * CSRF Protection Middleware for Elysia + * + * Validates Origin header against Host header for non-GET requests + * to prevent CSRF attacks when using cookie-based authentication. + * + * Usage: + * ```ts + * import { csrfProtection } from './lib/csrf' + * + * new Elysia() + * .use(csrfProtection()) + * .post('/api/protected', handler) + * ``` + */ +export const csrfProtection = () => { + return new Elysia({ name: 'csrf-protection' }) + .onBeforeHandle(({ request, set }) => { + const method = request.method.toUpperCase() + + // Only protect state-changing methods + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { + return + } + + // Get headers + const originHeader = request.headers.get('Origin') + // Use X-Forwarded-Host if behind a proxy, otherwise use Host + const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host') + + // Validate origin matches host + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + logger.warn('[CSRF] Request blocked', { + method, + origin: originHeader, + host: hostHeader, + path: new URL(request.url).pathname + }) + + set.status = 403 + return { + error: 'CSRF validation failed', + message: 'Request origin does not match host' + } + } + }) +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index f98cb81..484df27 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -14,6 +14,15 @@ export const logger = { } }, + // Warning logging (always logged but may be sanitized in production) + warn: (message: string, context?: Record) => { + if (isDev) { + console.warn(message, context); + } else { + console.warn(message); + } + }, + // Safe error logging - sanitizes in production error: (message: string, error?: any) => { if (isDev) { diff --git a/src/routes/domain.ts b/src/routes/domain.ts index a6fde9b..c38f2c2 100644 --- a/src/routes/domain.ts +++ b/src/routes/domain.ts @@ -170,9 +170,67 @@ export const domainRoutes = (client: NodeOAuthClient) => const { domain } = body as { domain: string }; const domainLower = domain.toLowerCase().trim(); - // Basic validation - if (!domainLower || domainLower.length < 3) { - throw new Error('Invalid domain'); + // Enhanced domain validation + // 1. Length check (RFC 1035: labels 1-63 chars, total max 253) + if (!domainLower || domainLower.length < 3 || domainLower.length > 253) { + throw new Error('Invalid domain: must be 3-253 characters'); + } + + // 2. Basic format validation + // - Must contain at least one dot (require TLD) + // - Valid characters: a-z, 0-9, hyphen, dot + // - No consecutive dots, no leading/trailing dots or hyphens + const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; + if (!domainPattern.test(domainLower)) { + throw new Error('Invalid domain format'); + } + + // 3. Validate each label (part between dots) + const labels = domainLower.split('.'); + for (const label of labels) { + if (label.length === 0 || label.length > 63) { + throw new Error('Invalid domain: label length must be 1-63 characters'); + } + if (label.startsWith('-') || label.endsWith('-')) { + throw new Error('Invalid domain: labels cannot start or end with hyphen'); + } + } + + // 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs) + const tld = labels[labels.length - 1]; + if (tld.length < 2 || /^\d+$/.test(tld)) { + throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric'); + } + + // 5. Homograph attack protection - block domains with mixed scripts or confusables + // Block non-ASCII characters (Punycode domains should be pre-converted) + if (!/^[a-z0-9.-]+$/.test(domainLower)) { + throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed'); + } + + // 6. Block localhost, internal IPs, and reserved domains + const blockedDomains = [ + 'localhost', + 'example.com', + 'example.org', + 'example.net', + 'test', + 'invalid', + 'local' + ]; + const blockedPatterns = [ + /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs + /^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address + ]; + + if (blockedDomains.includes(domainLower)) { + throw new Error('Invalid domain: reserved or blocked domain'); + } + + for (const pattern of blockedPatterns) { + if (pattern.test(domainLower)) { + throw new Error('Invalid domain: IP addresses not allowed'); + } } // Check if already exists -- 2.54.0