diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..559c5b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.git +.gitignore +*.md +.env +.env.local +.DS_Store +dist +*.log +.vscode +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a1b973 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Use official Bun image +FROM oven/bun:1.3 AS base + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json bun.lock* ./ + +# Install dependencies +RUN bun install --frozen-lockfile + +# Copy source code +COPY src ./src +COPY public ./public + +# Build the application (if needed) +# RUN bun run build + +# Set environment variables (can be overridden at runtime) +ENV PORT=3000 +ENV NODE_ENV=production + +# Expose the application port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +# Start the application +CMD ["bun", "src/index.ts"] diff --git a/hosting-service/.dockerignore b/hosting-service/.dockerignore new file mode 100644 index 0000000..24a2d5b --- /dev/null +++ b/hosting-service/.dockerignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules + +# Environment files +.env +.env.* +!.env.example + +# Git +.git +.gitignore + +# Cache +cache + +# Documentation +*.md +!README.md + +# Logs +*.log +npm-debug.log* +bun-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode +.idea +*.swp +*.swo +*~ diff --git a/hosting-service/Dockerfile b/hosting-service/Dockerfile new file mode 100644 index 0000000..05326a7 --- /dev/null +++ b/hosting-service/Dockerfile @@ -0,0 +1,31 @@ +# Use official Bun image +FROM oven/bun:1.3 AS base + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json bun.lock ./ + +# Install dependencies +RUN bun install --frozen-lockfile --production + +# Copy source code +COPY src ./src + +# Create cache directory +RUN mkdir -p ./cache/sites + +# Set environment variables (can be overridden at runtime) +ENV PORT=3001 +ENV NODE_ENV=production + +# Expose the application port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +# Start the application +CMD ["bun", "src/index.ts"] diff --git a/hosting-service/src/lib/utils.ts b/hosting-service/src/lib/utils.ts index 2f68363..01a44c3 100644 --- a/hosting-service/src/lib/utils.ts +++ b/hosting-service/src/lib/utils.ts @@ -153,8 +153,32 @@ async function cacheFileBlob( console.log('Cached file', filePath, content.length, 'bytes'); } +/** + * Sanitize a file path to prevent directory traversal attacks + * Removes any path segments that attempt to go up directories + */ +export function sanitizePath(filePath: string): string { + // Remove leading slashes + let cleaned = filePath.replace(/^\/+/, ''); + + // Split into segments and filter out dangerous ones + const segments = cleaned.split('/').filter(segment => { + // Remove empty segments + if (!segment || segment === '.') return false; + // Remove parent directory references + if (segment === '..') return false; + // Remove segments with null bytes + if (segment.includes('\0')) return false; + return true; + }); + + // Rejoin the safe segments + return segments.join('/'); +} + export function getCachedFilePath(did: string, site: string, filePath: string): string { - return `${CACHE_DIR}/${did}/${site}/${filePath}`; + const sanitizedPath = sanitizePath(filePath); + return `${CACHE_DIR}/${did}/${site}/${sanitizedPath}`; } export function isCached(did: string, site: string): boolean { diff --git a/hosting-service/src/server.ts b/hosting-service/src/server.ts index 54f7035..99b3fd7 100644 --- a/hosting-service/src/server.ts +++ b/hosting-service/src/server.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; -import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils'; +import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; import { existsSync } from 'fs'; @@ -9,6 +9,19 @@ const app = new Hono(); const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; +/** + * Validate site name (rkey) to prevent injection attacks + * Must match AT Protocol rkey format + */ +function isValidRkey(rkey: string): boolean { + if (!rkey || typeof rkey !== 'string') return false; + if (rkey.length < 1 || rkey.length > 512) return false; + if (rkey === '.' || rkey === '..') return false; + if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; + const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; + return validRkeyPattern.test(rkey); +} + // Helper to serve files from cache async function serveFromCache(did: string, rkey: string, filePath: string) { // Default to index.html if path is empty or ends with / @@ -119,10 +132,16 @@ async function ensureSiteCached(did: string, rkey: string): Promise { app.get('/s/:identifier/:site/*', async (c) => { const identifier = c.req.param('identifier'); const site = c.req.param('site'); - const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, ''); + const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, ''); + const filePath = sanitizePath(rawPath); console.log('[Direct] Serving', { identifier, site, filePath }); + // Validate site name (rkey) + if (!isValidRkey(site)) { + return c.text('Invalid site name', 400); + } + // Resolve identifier to DID const did = await resolveDid(identifier); if (!did) { @@ -143,7 +162,8 @@ app.get('/s/:identifier/:site/*', async (c) => { // Route 3: DNS routing for custom domains - /hash.dns.wisp.place/* app.get('/*', async (c) => { const hostname = c.req.header('host') || ''; - const path = c.req.path.replace(/^\//, ''); + const rawPath = c.req.path.replace(/^\//, ''); + const path = sanitizePath(rawPath); console.log('[Request]', { hostname, path }); @@ -165,6 +185,10 @@ app.get('/*', async (c) => { } const rkey = customDomain.rkey || 'self'; + if (!isValidRkey(rkey)) { + return c.text('Invalid site configuration', 500); + } + const cached = await ensureSiteCached(customDomain.did, rkey); if (!cached) { return c.text('Site not found', 404); @@ -185,6 +209,10 @@ app.get('/*', async (c) => { } const rkey = domainInfo.rkey || 'self'; + if (!isValidRkey(rkey)) { + return c.text('Invalid site configuration', 500); + } + const cached = await ensureSiteCached(domainInfo.did, rkey); if (!cached) { return c.text('Site not found', 404); @@ -202,6 +230,10 @@ app.get('/*', async (c) => { } const rkey = customDomain.rkey || 'self'; + if (!isValidRkey(rkey)) { + return c.text('Invalid site configuration', 500); + } + const cached = await ensureSiteCached(customDomain.did, rkey); if (!cached) { return c.text('Site not found', 404); diff --git a/src/routes/domain.ts b/src/routes/domain.ts index 0db9cd5..3c6e8c5 100644 --- a/src/routes/domain.ts +++ b/src/routes/domain.ts @@ -229,6 +229,16 @@ export const domainRoutes = (client: NodeOAuthClient) => try { const { id } = params; + // Verify ownership before deleting + const domainInfo = await getCustomDomainById(id); + if (!domainInfo) { + throw new Error('Domain not found'); + } + + if (domainInfo.did !== auth.did) { + throw new Error('Unauthorized: You do not own this domain'); + } + // Delete from database await deleteCustomDomain(id); @@ -256,6 +266,16 @@ export const domainRoutes = (client: NodeOAuthClient) => const { id } = params; const { siteRkey } = body as { siteRkey: string | null }; + // Verify ownership before updating + const domainInfo = await getCustomDomainById(id); + if (!domainInfo) { + throw new Error('Domain not found'); + } + + if (domainInfo.did !== auth.did) { + throw new Error('Unauthorized: You do not own this domain'); + } + // Update custom domain to point to this site await updateCustomDomainRkey(id, siteRkey || 'self'); diff --git a/src/routes/wisp.ts b/src/routes/wisp.ts index a3d33b0..b413ace 100644 --- a/src/routes/wisp.ts +++ b/src/routes/wisp.ts @@ -11,6 +11,32 @@ import { } from '../lib/wisp-utils' import { upsertSite } from '../lib/db' +/** + * Validate site name (rkey) according to AT Protocol specifications + * - Must be 1-512 characters + * - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons + * - Cannot be just "." or ".." + * - Cannot contain path traversal sequences + */ +function isValidSiteName(siteName: string): boolean { + if (!siteName || typeof siteName !== 'string') return false; + + // Length check (AT Protocol rkey limit) + if (siteName.length < 1 || siteName.length > 512) return false; + + // Check for path traversal + if (siteName === '.' || siteName === '..') return false; + if (siteName.includes('/') || siteName.includes('\\')) return false; + if (siteName.includes('\0')) return false; + + // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons + // Based on NSID format rules + const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; + if (!validRkeyPattern.test(siteName)) return false; + + return true; +} + export const wispRoutes = (client: NodeOAuthClient) => new Elysia({ prefix: '/wisp' }) .derive(async ({ cookie }) => { @@ -33,6 +59,11 @@ export const wispRoutes = (client: NodeOAuthClient) => throw new Error('Site name is required') } + if (!isValidSiteName(siteName)) { + console.error('❌ Invalid site name format'); + throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons') + } + console.log('✅ Initial validation passed'); // Check if files were provided