+11
.dockerignore
+11
.dockerignore
+32
Dockerfile
+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
+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
+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
+25
-1
hosting-service/src/lib/utils.ts
···
153
console.log('Cached file', filePath, content.length, 'bytes');
154
}
155
156
export function getCachedFilePath(did: string, site: string, filePath: string): string {
157
-
return `${CACHE_DIR}/${did}/${site}/${filePath}`;
158
}
159
160
export function isCached(did: string, site: string): boolean {
···
153
console.log('Cached file', filePath, content.length, 'bytes');
154
}
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
+
179
export function getCachedFilePath(did: string, site: string, filePath: string): string {
180
+
const sanitizedPath = sanitizePath(filePath);
181
+
return `${CACHE_DIR}/${did}/${site}/${sanitizedPath}`;
182
}
183
184
export function isCached(did: string, site: string): boolean {
+35
-3
hosting-service/src/server.ts
+35
-3
hosting-service/src/server.ts
···
1
import { Hono } from 'hono';
2
import { serveStatic } from 'hono/bun';
3
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
4
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils';
5
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
6
import { existsSync } from 'fs';
7
8
const app = new Hono();
9
10
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
11
12
// Helper to serve files from cache
13
async function serveFromCache(did: string, rkey: string, filePath: string) {
···
119
app.get('/s/:identifier/:site/*', async (c) => {
120
const identifier = c.req.param('identifier');
121
const site = c.req.param('site');
122
-
const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
123
124
console.log('[Direct] Serving', { identifier, site, filePath });
125
126
// Resolve identifier to DID
127
const did = await resolveDid(identifier);
128
if (!did) {
···
143
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
144
app.get('/*', async (c) => {
145
const hostname = c.req.header('host') || '';
146
-
const path = c.req.path.replace(/^\//, '');
147
148
console.log('[Request]', { hostname, path });
149
···
165
}
166
167
const rkey = customDomain.rkey || 'self';
168
const cached = await ensureSiteCached(customDomain.did, rkey);
169
if (!cached) {
170
return c.text('Site not found', 404);
···
185
}
186
187
const rkey = domainInfo.rkey || 'self';
188
const cached = await ensureSiteCached(domainInfo.did, rkey);
189
if (!cached) {
190
return c.text('Site not found', 404);
···
202
}
203
204
const rkey = customDomain.rkey || 'self';
205
const cached = await ensureSiteCached(customDomain.did, rkey);
206
if (!cached) {
207
return c.text('Site not found', 404);
···
1
import { Hono } from 'hono';
2
import { serveStatic } from 'hono/bun';
3
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
4
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
5
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
6
import { existsSync } from 'fs';
7
8
const app = new Hono();
9
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
+
}
24
25
// Helper to serve files from cache
26
async function serveFromCache(did: string, rkey: string, filePath: string) {
···
132
app.get('/s/:identifier/:site/*', async (c) => {
133
const identifier = c.req.param('identifier');
134
const site = c.req.param('site');
135
+
const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
136
+
const filePath = sanitizePath(rawPath);
137
138
console.log('[Direct] Serving', { identifier, site, filePath });
139
140
+
// Validate site name (rkey)
141
+
if (!isValidRkey(site)) {
142
+
return c.text('Invalid site name', 400);
143
+
}
144
+
145
// Resolve identifier to DID
146
const did = await resolveDid(identifier);
147
if (!did) {
···
162
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
163
app.get('/*', async (c) => {
164
const hostname = c.req.header('host') || '';
165
+
const rawPath = c.req.path.replace(/^\//, '');
166
+
const path = sanitizePath(rawPath);
167
168
console.log('[Request]', { hostname, path });
169
···
185
}
186
187
const rkey = customDomain.rkey || 'self';
188
+
if (!isValidRkey(rkey)) {
189
+
return c.text('Invalid site configuration', 500);
190
+
}
191
+
192
const cached = await ensureSiteCached(customDomain.did, rkey);
193
if (!cached) {
194
return c.text('Site not found', 404);
···
209
}
210
211
const rkey = domainInfo.rkey || 'self';
212
+
if (!isValidRkey(rkey)) {
213
+
return c.text('Invalid site configuration', 500);
214
+
}
215
+
216
const cached = await ensureSiteCached(domainInfo.did, rkey);
217
if (!cached) {
218
return c.text('Site not found', 404);
···
230
}
231
232
const rkey = customDomain.rkey || 'self';
233
+
if (!isValidRkey(rkey)) {
234
+
return c.text('Invalid site configuration', 500);
235
+
}
236
+
237
const cached = await ensureSiteCached(customDomain.did, rkey);
238
if (!cached) {
239
return c.text('Site not found', 404);
+20
src/routes/domain.ts
+20
src/routes/domain.ts
···
229
try {
230
const { id } = params;
231
232
// Delete from database
233
await deleteCustomDomain(id);
234
···
255
try {
256
const { id } = params;
257
const { siteRkey } = body as { siteRkey: string | null };
258
259
// Update custom domain to point to this site
260
await updateCustomDomainRkey(id, siteRkey || 'self');
···
229
try {
230
const { id } = params;
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
+
242
// Delete from database
243
await deleteCustomDomain(id);
244
···
265
try {
266
const { id } = params;
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
+
}
278
279
// Update custom domain to point to this site
280
await updateCustomDomainRkey(id, siteRkey || 'self');
+31
src/routes/wisp.ts
+31
src/routes/wisp.ts
···
11
} from '../lib/wisp-utils'
12
import { upsertSite } from '../lib/db'
13
14
export const wispRoutes = (client: NodeOAuthClient) =>
15
new Elysia({ prefix: '/wisp' })
16
.derive(async ({ cookie }) => {
···
31
if (!siteName) {
32
console.error('❌ Site name is required');
33
throw new Error('Site name is required')
34
}
35
36
console.log('✅ Initial validation passed');
···
11
} from '../lib/wisp-utils'
12
import { upsertSite } from '../lib/db'
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
+
40
export const wispRoutes = (client: NodeOAuthClient) =>
41
new Elysia({ prefix: '/wisp' })
42
.derive(async ({ cookie }) => {
···
57
if (!siteName) {
58
console.error('❌ Site name is required');
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')
65
}
66
67
console.log('✅ Initial validation passed');