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

add docker files, route santization

Changed files
+219 -4
hosting-service
src
+11
.dockerignore
··· 1 + node_modules 2 + .git 3 + .gitignore 4 + *.md 5 + .env 6 + .env.local 7 + .DS_Store 8 + dist 9 + *.log 10 + .vscode 11 + .idea
+32
Dockerfile
··· 1 + # Use official Bun image 2 + FROM oven/bun:1.3 AS base 3 + 4 + # Set working directory 5 + WORKDIR /app 6 + 7 + # Copy package files 8 + COPY package.json bun.lock* ./ 9 + 10 + # Install dependencies 11 + RUN bun install --frozen-lockfile 12 + 13 + # Copy source code 14 + COPY src ./src 15 + COPY public ./public 16 + 17 + # Build the application (if needed) 18 + # RUN bun run build 19 + 20 + # Set environment variables (can be overridden at runtime) 21 + ENV PORT=3000 22 + ENV NODE_ENV=production 23 + 24 + # Expose the application port 25 + EXPOSE 3000 26 + 27 + # Health check 28 + HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 29 + CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 30 + 31 + # Start the application 32 + CMD ["bun", "src/index.ts"]
+34
hosting-service/.dockerignore
··· 1 + # Dependencies 2 + node_modules 3 + 4 + # Environment files 5 + .env 6 + .env.* 7 + !.env.example 8 + 9 + # Git 10 + .git 11 + .gitignore 12 + 13 + # Cache 14 + cache 15 + 16 + # Documentation 17 + *.md 18 + !README.md 19 + 20 + # Logs 21 + *.log 22 + npm-debug.log* 23 + bun-debug.log* 24 + 25 + # OS files 26 + .DS_Store 27 + Thumbs.db 28 + 29 + # IDE 30 + .vscode 31 + .idea 32 + *.swp 33 + *.swo 34 + *~
+31
hosting-service/Dockerfile
··· 1 + # Use official Bun image 2 + FROM oven/bun:1.3 AS base 3 + 4 + # Set working directory 5 + WORKDIR /app 6 + 7 + # Copy package files 8 + COPY package.json bun.lock ./ 9 + 10 + # Install dependencies 11 + RUN bun install --frozen-lockfile --production 12 + 13 + # Copy source code 14 + COPY src ./src 15 + 16 + # Create cache directory 17 + RUN mkdir -p ./cache/sites 18 + 19 + # Set environment variables (can be overridden at runtime) 20 + ENV PORT=3001 21 + ENV NODE_ENV=production 22 + 23 + # Expose the application port 24 + EXPOSE 3001 25 + 26 + # Health check 27 + HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 28 + CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 29 + 30 + # Start the application 31 + CMD ["bun", "src/index.ts"]
+25 -1
hosting-service/src/lib/utils.ts
··· 153 153 console.log('Cached file', filePath, content.length, 'bytes'); 154 154 } 155 155 156 + /** 157 + * Sanitize a file path to prevent directory traversal attacks 158 + * Removes any path segments that attempt to go up directories 159 + */ 160 + export function sanitizePath(filePath: string): string { 161 + // Remove leading slashes 162 + let cleaned = filePath.replace(/^\/+/, ''); 163 + 164 + // Split into segments and filter out dangerous ones 165 + const segments = cleaned.split('/').filter(segment => { 166 + // Remove empty segments 167 + if (!segment || segment === '.') return false; 168 + // Remove parent directory references 169 + if (segment === '..') return false; 170 + // Remove segments with null bytes 171 + if (segment.includes('\0')) return false; 172 + return true; 173 + }); 174 + 175 + // Rejoin the safe segments 176 + return segments.join('/'); 177 + } 178 + 156 179 export function getCachedFilePath(did: string, site: string, filePath: string): string { 157 - return `${CACHE_DIR}/${did}/${site}/${filePath}`; 180 + const sanitizedPath = sanitizePath(filePath); 181 + return `${CACHE_DIR}/${did}/${site}/${sanitizedPath}`; 158 182 } 159 183 160 184 export function isCached(did: string, site: string): boolean {
+35 -3
hosting-service/src/server.ts
··· 1 1 import { Hono } from 'hono'; 2 2 import { serveStatic } from 'hono/bun'; 3 3 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4 - import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils'; 4 + import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 5 5 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6 6 import { existsSync } from 'fs'; 7 7 8 8 const app = new Hono(); 9 9 10 10 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 11 + 12 + /** 13 + * Validate site name (rkey) to prevent injection attacks 14 + * Must match AT Protocol rkey format 15 + */ 16 + function isValidRkey(rkey: string): boolean { 17 + if (!rkey || typeof rkey !== 'string') return false; 18 + if (rkey.length < 1 || rkey.length > 512) return false; 19 + if (rkey === '.' || rkey === '..') return false; 20 + if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 21 + const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 22 + return validRkeyPattern.test(rkey); 23 + } 11 24 12 25 // Helper to serve files from cache 13 26 async function serveFromCache(did: string, rkey: string, filePath: string) { ··· 119 132 app.get('/s/:identifier/:site/*', async (c) => { 120 133 const identifier = c.req.param('identifier'); 121 134 const site = c.req.param('site'); 122 - const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, ''); 135 + const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, ''); 136 + const filePath = sanitizePath(rawPath); 123 137 124 138 console.log('[Direct] Serving', { identifier, site, filePath }); 125 139 140 + // Validate site name (rkey) 141 + if (!isValidRkey(site)) { 142 + return c.text('Invalid site name', 400); 143 + } 144 + 126 145 // Resolve identifier to DID 127 146 const did = await resolveDid(identifier); 128 147 if (!did) { ··· 143 162 // Route 3: DNS routing for custom domains - /hash.dns.wisp.place/* 144 163 app.get('/*', async (c) => { 145 164 const hostname = c.req.header('host') || ''; 146 - const path = c.req.path.replace(/^\//, ''); 165 + const rawPath = c.req.path.replace(/^\//, ''); 166 + const path = sanitizePath(rawPath); 147 167 148 168 console.log('[Request]', { hostname, path }); 149 169 ··· 165 185 } 166 186 167 187 const rkey = customDomain.rkey || 'self'; 188 + if (!isValidRkey(rkey)) { 189 + return c.text('Invalid site configuration', 500); 190 + } 191 + 168 192 const cached = await ensureSiteCached(customDomain.did, rkey); 169 193 if (!cached) { 170 194 return c.text('Site not found', 404); ··· 185 209 } 186 210 187 211 const rkey = domainInfo.rkey || 'self'; 212 + if (!isValidRkey(rkey)) { 213 + return c.text('Invalid site configuration', 500); 214 + } 215 + 188 216 const cached = await ensureSiteCached(domainInfo.did, rkey); 189 217 if (!cached) { 190 218 return c.text('Site not found', 404); ··· 202 230 } 203 231 204 232 const rkey = customDomain.rkey || 'self'; 233 + if (!isValidRkey(rkey)) { 234 + return c.text('Invalid site configuration', 500); 235 + } 236 + 205 237 const cached = await ensureSiteCached(customDomain.did, rkey); 206 238 if (!cached) { 207 239 return c.text('Site not found', 404);
+20
src/routes/domain.ts
··· 229 229 try { 230 230 const { id } = params; 231 231 232 + // Verify ownership before deleting 233 + const domainInfo = await getCustomDomainById(id); 234 + if (!domainInfo) { 235 + throw new Error('Domain not found'); 236 + } 237 + 238 + if (domainInfo.did !== auth.did) { 239 + throw new Error('Unauthorized: You do not own this domain'); 240 + } 241 + 232 242 // Delete from database 233 243 await deleteCustomDomain(id); 234 244 ··· 255 265 try { 256 266 const { id } = params; 257 267 const { siteRkey } = body as { siteRkey: string | null }; 268 + 269 + // Verify ownership before updating 270 + const domainInfo = await getCustomDomainById(id); 271 + if (!domainInfo) { 272 + throw new Error('Domain not found'); 273 + } 274 + 275 + if (domainInfo.did !== auth.did) { 276 + throw new Error('Unauthorized: You do not own this domain'); 277 + } 258 278 259 279 // Update custom domain to point to this site 260 280 await updateCustomDomainRkey(id, siteRkey || 'self');
+31
src/routes/wisp.ts
··· 11 11 } from '../lib/wisp-utils' 12 12 import { upsertSite } from '../lib/db' 13 13 14 + /** 15 + * Validate site name (rkey) according to AT Protocol specifications 16 + * - Must be 1-512 characters 17 + * - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons 18 + * - Cannot be just "." or ".." 19 + * - Cannot contain path traversal sequences 20 + */ 21 + function isValidSiteName(siteName: string): boolean { 22 + if (!siteName || typeof siteName !== 'string') return false; 23 + 24 + // Length check (AT Protocol rkey limit) 25 + if (siteName.length < 1 || siteName.length > 512) return false; 26 + 27 + // Check for path traversal 28 + if (siteName === '.' || siteName === '..') return false; 29 + if (siteName.includes('/') || siteName.includes('\\')) return false; 30 + if (siteName.includes('\0')) return false; 31 + 32 + // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons 33 + // Based on NSID format rules 34 + const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 35 + if (!validRkeyPattern.test(siteName)) return false; 36 + 37 + return true; 38 + } 39 + 14 40 export const wispRoutes = (client: NodeOAuthClient) => 15 41 new Elysia({ prefix: '/wisp' }) 16 42 .derive(async ({ cookie }) => { ··· 31 57 if (!siteName) { 32 58 console.error('❌ Site name is required'); 33 59 throw new Error('Site name is required') 60 + } 61 + 62 + if (!isValidSiteName(siteName)) { 63 + console.error('❌ Invalid site name format'); 64 + throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons') 34 65 } 35 66 36 67 console.log('✅ Initial validation passed');