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
···
+
node_modules
+
.git
+
.gitignore
+
*.md
+
.env
+
.env.local
+
.DS_Store
+
dist
+
*.log
+
.vscode
+
.idea
+32
Dockerfile
···
+
# 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"]
+34
hosting-service/.dockerignore
···
+
# 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
+
*~
+31
hosting-service/Dockerfile
···
+
# 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"]
+25 -1
hosting-service/src/lib/utils.ts
···
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 {
+35 -3
hosting-service/src/server.ts
···
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';
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) {
···
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) {
···
// 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 });
···
}
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);
···
}
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);
···
}
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);
+20
src/routes/domain.ts
···
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);
···
try {
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');
+31
src/routes/wisp.ts
···
} 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 }) => {
···
if (!siteName) {
console.error('❌ Site name is required');
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');