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

better csrf handling

Changed files
+169 -11
hosting-service
src
src
+14 -6
hosting-service/src/server.ts
···
}
// 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;
}
···
}
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);
···
// 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)) {
+5 -2
src/index.ts
···
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}`,
···
prefix: '/'
})
)
+
.use(csrfProtection())
.use(authRoutes(client))
.use(wispRoutes(client))
.use(domainRoutes(client))
···
.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)
+80
src/lib/csrf.ts
···
+
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'
+
}
+
}
+
})
+
}
+9
src/lib/logger.ts
···
}
},
+
// Warning logging (always logged but may be sanitized in production)
+
warn: (message: string, context?: Record<string, any>) => {
+
if (isDev) {
+
console.warn(message, context);
+
} else {
+
console.warn(message);
+
}
+
},
+
// Safe error logging - sanitizes in production
error: (message: string, error?: any) => {
if (isDev) {
+61 -3
src/routes/domain.ts
···
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