+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
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
+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
+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
+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');