···11+# Use official Bun image
22+FROM oven/bun:1.3 AS base
33+44+# Set working directory
55+WORKDIR /app
66+77+# Copy package files
88+COPY package.json bun.lock* ./
99+1010+# Install dependencies
1111+RUN bun install --frozen-lockfile
1212+1313+# Copy source code
1414+COPY src ./src
1515+COPY public ./public
1616+1717+# Build the application (if needed)
1818+# RUN bun run build
1919+2020+# Set environment variables (can be overridden at runtime)
2121+ENV PORT=3000
2222+ENV NODE_ENV=production
2323+2424+# Expose the application port
2525+EXPOSE 3000
2626+2727+# Health check
2828+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
2929+ CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
3030+3131+# Start the application
3232+CMD ["bun", "src/index.ts"]
···11+# Use official Bun image
22+FROM oven/bun:1.3 AS base
33+44+# Set working directory
55+WORKDIR /app
66+77+# Copy package files
88+COPY package.json bun.lock ./
99+1010+# Install dependencies
1111+RUN bun install --frozen-lockfile --production
1212+1313+# Copy source code
1414+COPY src ./src
1515+1616+# Create cache directory
1717+RUN mkdir -p ./cache/sites
1818+1919+# Set environment variables (can be overridden at runtime)
2020+ENV PORT=3001
2121+ENV NODE_ENV=production
2222+2323+# Expose the application port
2424+EXPOSE 3001
2525+2626+# Health check
2727+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
2828+ CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
2929+3030+# Start the application
3131+CMD ["bun", "src/index.ts"]
+25-1
hosting-service/src/lib/utils.ts
···153153 console.log('Cached file', filePath, content.length, 'bytes');
154154}
155155156156+/**
157157+ * Sanitize a file path to prevent directory traversal attacks
158158+ * Removes any path segments that attempt to go up directories
159159+ */
160160+export function sanitizePath(filePath: string): string {
161161+ // Remove leading slashes
162162+ let cleaned = filePath.replace(/^\/+/, '');
163163+164164+ // Split into segments and filter out dangerous ones
165165+ const segments = cleaned.split('/').filter(segment => {
166166+ // Remove empty segments
167167+ if (!segment || segment === '.') return false;
168168+ // Remove parent directory references
169169+ if (segment === '..') return false;
170170+ // Remove segments with null bytes
171171+ if (segment.includes('\0')) return false;
172172+ return true;
173173+ });
174174+175175+ // Rejoin the safe segments
176176+ return segments.join('/');
177177+}
178178+156179export function getCachedFilePath(did: string, site: string, filePath: string): string {
157157- return `${CACHE_DIR}/${did}/${site}/${filePath}`;
180180+ const sanitizedPath = sanitizePath(filePath);
181181+ return `${CACHE_DIR}/${did}/${site}/${sanitizedPath}`;
158182}
159183160184export function isCached(did: string, site: string): boolean {
+35-3
hosting-service/src/server.ts
···11import { Hono } from 'hono';
22import { serveStatic } from 'hono/bun';
33import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
44-import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils';
44+import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
55import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
66import { existsSync } from 'fs';
7788const app = new Hono();
991010const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
1111+1212+/**
1313+ * Validate site name (rkey) to prevent injection attacks
1414+ * Must match AT Protocol rkey format
1515+ */
1616+function isValidRkey(rkey: string): boolean {
1717+ if (!rkey || typeof rkey !== 'string') return false;
1818+ if (rkey.length < 1 || rkey.length > 512) return false;
1919+ if (rkey === '.' || rkey === '..') return false;
2020+ if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
2121+ const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
2222+ return validRkeyPattern.test(rkey);
2323+}
11241225// Helper to serve files from cache
1326async function serveFromCache(did: string, rkey: string, filePath: string) {
···119132app.get('/s/:identifier/:site/*', async (c) => {
120133 const identifier = c.req.param('identifier');
121134 const site = c.req.param('site');
122122- const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
135135+ const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
136136+ const filePath = sanitizePath(rawPath);
123137124138 console.log('[Direct] Serving', { identifier, site, filePath });
125139140140+ // Validate site name (rkey)
141141+ if (!isValidRkey(site)) {
142142+ return c.text('Invalid site name', 400);
143143+ }
144144+126145 // Resolve identifier to DID
127146 const did = await resolveDid(identifier);
128147 if (!did) {
···143162// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
144163app.get('/*', async (c) => {
145164 const hostname = c.req.header('host') || '';
146146- const path = c.req.path.replace(/^\//, '');
165165+ const rawPath = c.req.path.replace(/^\//, '');
166166+ const path = sanitizePath(rawPath);
147167148168 console.log('[Request]', { hostname, path });
149169···165185 }
166186167187 const rkey = customDomain.rkey || 'self';
188188+ if (!isValidRkey(rkey)) {
189189+ return c.text('Invalid site configuration', 500);
190190+ }
191191+168192 const cached = await ensureSiteCached(customDomain.did, rkey);
169193 if (!cached) {
170194 return c.text('Site not found', 404);
···185209 }
186210187211 const rkey = domainInfo.rkey || 'self';
212212+ if (!isValidRkey(rkey)) {
213213+ return c.text('Invalid site configuration', 500);
214214+ }
215215+188216 const cached = await ensureSiteCached(domainInfo.did, rkey);
189217 if (!cached) {
190218 return c.text('Site not found', 404);
···202230 }
203231204232 const rkey = customDomain.rkey || 'self';
233233+ if (!isValidRkey(rkey)) {
234234+ return c.text('Invalid site configuration', 500);
235235+ }
236236+205237 const cached = await ensureSiteCached(customDomain.did, rkey);
206238 if (!cached) {
207239 return c.text('Site not found', 404);
+20
src/routes/domain.ts
···229229 try {
230230 const { id } = params;
231231232232+ // Verify ownership before deleting
233233+ const domainInfo = await getCustomDomainById(id);
234234+ if (!domainInfo) {
235235+ throw new Error('Domain not found');
236236+ }
237237+238238+ if (domainInfo.did !== auth.did) {
239239+ throw new Error('Unauthorized: You do not own this domain');
240240+ }
241241+232242 // Delete from database
233243 await deleteCustomDomain(id);
234244···255265 try {
256266 const { id } = params;
257267 const { siteRkey } = body as { siteRkey: string | null };
268268+269269+ // Verify ownership before updating
270270+ const domainInfo = await getCustomDomainById(id);
271271+ if (!domainInfo) {
272272+ throw new Error('Domain not found');
273273+ }
274274+275275+ if (domainInfo.did !== auth.did) {
276276+ throw new Error('Unauthorized: You do not own this domain');
277277+ }
258278259279 // Update custom domain to point to this site
260280 await updateCustomDomainRkey(id, siteRkey || 'self');
+31
src/routes/wisp.ts
···1111} from '../lib/wisp-utils'
1212import { upsertSite } from '../lib/db'
13131414+/**
1515+ * Validate site name (rkey) according to AT Protocol specifications
1616+ * - Must be 1-512 characters
1717+ * - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons
1818+ * - Cannot be just "." or ".."
1919+ * - Cannot contain path traversal sequences
2020+ */
2121+function isValidSiteName(siteName: string): boolean {
2222+ if (!siteName || typeof siteName !== 'string') return false;
2323+2424+ // Length check (AT Protocol rkey limit)
2525+ if (siteName.length < 1 || siteName.length > 512) return false;
2626+2727+ // Check for path traversal
2828+ if (siteName === '.' || siteName === '..') return false;
2929+ if (siteName.includes('/') || siteName.includes('\\')) return false;
3030+ if (siteName.includes('\0')) return false;
3131+3232+ // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons
3333+ // Based on NSID format rules
3434+ const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
3535+ if (!validRkeyPattern.test(siteName)) return false;
3636+3737+ return true;
3838+}
3939+1440export const wispRoutes = (client: NodeOAuthClient) =>
1541 new Elysia({ prefix: '/wisp' })
1642 .derive(async ({ cookie }) => {
···3157 if (!siteName) {
3258 console.error('❌ Site name is required');
3359 throw new Error('Site name is required')
6060+ }
6161+6262+ if (!isValidSiteName(siteName)) {
6363+ console.error('❌ Invalid site name format');
6464+ throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons')
3465 }
35663667 console.log('✅ Initial validation passed');