+14
-6
hosting-service/src/server.ts
+14
-6
hosting-service/src/server.ts
···
119
119
}
120
120
121
121
// Fetch and cache the site
122
-
const record = await fetchSiteRecord(did, rkey);
123
-
if (!record) {
122
+
const siteData = await fetchSiteRecord(did, rkey);
123
+
if (!siteData) {
124
124
console.error('Site record not found', did, rkey);
125
125
return false;
126
126
}
···
132
132
}
133
133
134
134
try {
135
-
await downloadAndCacheSite(did, rkey, record, pdsEndpoint);
135
+
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
136
136
return true;
137
137
} catch (err) {
138
138
console.error('Failed to cache site', did, rkey, err);
···
153
153
154
154
// Check if this is sites.wisp.place subdomain
155
155
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
156
-
// Extract identifier and site from path: /did:plc:123abc/sitename/file.html
157
-
const pathParts = rawPath.split('/');
156
+
// Sanitize the path FIRST to prevent path traversal
157
+
const sanitizedFullPath = sanitizePath(rawPath);
158
+
159
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
160
+
const pathParts = sanitizedFullPath.split('/');
158
161
if (pathParts.length < 2) {
159
162
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
160
163
}
161
164
162
165
const identifier = pathParts[0];
163
166
const site = pathParts[1];
164
-
const filePath = sanitizePath(pathParts.slice(2).join('/'));
167
+
const filePath = pathParts.slice(2).join('/');
165
168
166
169
console.log('[Sites] Serving', { identifier, site, filePath });
170
+
171
+
// Additional validation: identifier must be a valid DID or handle format
172
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
173
+
return c.text('Invalid identifier', 400);
174
+
}
167
175
168
176
// Validate site name (rkey)
169
177
if (!isValidRkey(site)) {
+5
-2
src/index.ts
+5
-2
src/index.ts
···
16
16
import { wispRoutes } from './routes/wisp'
17
17
import { domainRoutes } from './routes/domain'
18
18
import { userRoutes } from './routes/user'
19
+
import { csrfProtection } from './lib/csrf'
19
20
20
21
const config: Config = {
21
22
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
74
75
prefix: '/'
75
76
})
76
77
)
78
+
.use(csrfProtection())
77
79
.use(authRoutes(client))
78
80
.use(wispRoutes(client))
79
81
.use(domainRoutes(client))
···
96
98
.use(cors({
97
99
origin: config.domain,
98
100
credentials: true,
99
-
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
100
-
allowedHeaders: ['Content-Type', 'Authorization'],
101
+
methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'],
102
+
allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'],
103
+
exposeHeaders: ['Content-Type'],
101
104
maxAge: 86400 // 24 hours
102
105
}))
103
106
.listen(8000)
+80
src/lib/csrf.ts
+80
src/lib/csrf.ts
···
1
+
import { Elysia } from 'elysia'
2
+
import { logger } from './logger'
3
+
4
+
/**
5
+
* CSRF Protection using Origin/Host header verification
6
+
* Based on Lucia's recommended approach for cookie-based authentication
7
+
*
8
+
* This validates that the Origin header matches the Host header for
9
+
* state-changing requests (POST, PUT, DELETE, PATCH).
10
+
*/
11
+
12
+
/**
13
+
* Verify that the request origin matches the expected host
14
+
* @param origin - The Origin header value
15
+
* @param allowedHosts - Array of allowed host values
16
+
* @returns true if origin is valid, false otherwise
17
+
*/
18
+
export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean {
19
+
if (!origin) {
20
+
return false
21
+
}
22
+
23
+
try {
24
+
const originUrl = new URL(origin)
25
+
const originHost = originUrl.host
26
+
27
+
return allowedHosts.some(host => originHost === host)
28
+
} catch {
29
+
// Invalid URL
30
+
return false
31
+
}
32
+
}
33
+
34
+
/**
35
+
* CSRF Protection Middleware for Elysia
36
+
*
37
+
* Validates Origin header against Host header for non-GET requests
38
+
* to prevent CSRF attacks when using cookie-based authentication.
39
+
*
40
+
* Usage:
41
+
* ```ts
42
+
* import { csrfProtection } from './lib/csrf'
43
+
*
44
+
* new Elysia()
45
+
* .use(csrfProtection())
46
+
* .post('/api/protected', handler)
47
+
* ```
48
+
*/
49
+
export const csrfProtection = () => {
50
+
return new Elysia({ name: 'csrf-protection' })
51
+
.onBeforeHandle(({ request, set }) => {
52
+
const method = request.method.toUpperCase()
53
+
54
+
// Only protect state-changing methods
55
+
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
56
+
return
57
+
}
58
+
59
+
// Get headers
60
+
const originHeader = request.headers.get('Origin')
61
+
// Use X-Forwarded-Host if behind a proxy, otherwise use Host
62
+
const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host')
63
+
64
+
// Validate origin matches host
65
+
if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
66
+
logger.warn('[CSRF] Request blocked', {
67
+
method,
68
+
origin: originHeader,
69
+
host: hostHeader,
70
+
path: new URL(request.url).pathname
71
+
})
72
+
73
+
set.status = 403
74
+
return {
75
+
error: 'CSRF validation failed',
76
+
message: 'Request origin does not match host'
77
+
}
78
+
}
79
+
})
80
+
}
+9
src/lib/logger.ts
+9
src/lib/logger.ts
···
14
14
}
15
15
},
16
16
17
+
// Warning logging (always logged but may be sanitized in production)
18
+
warn: (message: string, context?: Record<string, any>) => {
19
+
if (isDev) {
20
+
console.warn(message, context);
21
+
} else {
22
+
console.warn(message);
23
+
}
24
+
},
25
+
17
26
// Safe error logging - sanitizes in production
18
27
error: (message: string, error?: any) => {
19
28
if (isDev) {
+61
-3
src/routes/domain.ts
+61
-3
src/routes/domain.ts
···
170
170
const { domain } = body as { domain: string };
171
171
const domainLower = domain.toLowerCase().trim();
172
172
173
-
// Basic validation
174
-
if (!domainLower || domainLower.length < 3) {
175
-
throw new Error('Invalid domain');
173
+
// Enhanced domain validation
174
+
// 1. Length check (RFC 1035: labels 1-63 chars, total max 253)
175
+
if (!domainLower || domainLower.length < 3 || domainLower.length > 253) {
176
+
throw new Error('Invalid domain: must be 3-253 characters');
177
+
}
178
+
179
+
// 2. Basic format validation
180
+
// - Must contain at least one dot (require TLD)
181
+
// - Valid characters: a-z, 0-9, hyphen, dot
182
+
// - No consecutive dots, no leading/trailing dots or hyphens
183
+
const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
184
+
if (!domainPattern.test(domainLower)) {
185
+
throw new Error('Invalid domain format');
186
+
}
187
+
188
+
// 3. Validate each label (part between dots)
189
+
const labels = domainLower.split('.');
190
+
for (const label of labels) {
191
+
if (label.length === 0 || label.length > 63) {
192
+
throw new Error('Invalid domain: label length must be 1-63 characters');
193
+
}
194
+
if (label.startsWith('-') || label.endsWith('-')) {
195
+
throw new Error('Invalid domain: labels cannot start or end with hyphen');
196
+
}
197
+
}
198
+
199
+
// 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs)
200
+
const tld = labels[labels.length - 1];
201
+
if (tld.length < 2 || /^\d+$/.test(tld)) {
202
+
throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric');
203
+
}
204
+
205
+
// 5. Homograph attack protection - block domains with mixed scripts or confusables
206
+
// Block non-ASCII characters (Punycode domains should be pre-converted)
207
+
if (!/^[a-z0-9.-]+$/.test(domainLower)) {
208
+
throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed');
209
+
}
210
+
211
+
// 6. Block localhost, internal IPs, and reserved domains
212
+
const blockedDomains = [
213
+
'localhost',
214
+
'example.com',
215
+
'example.org',
216
+
'example.net',
217
+
'test',
218
+
'invalid',
219
+
'local'
220
+
];
221
+
const blockedPatterns = [
222
+
/^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs
223
+
/^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address
224
+
];
225
+
226
+
if (blockedDomains.includes(domainLower)) {
227
+
throw new Error('Invalid domain: reserved or blocked domain');
228
+
}
229
+
230
+
for (const pattern of blockedPatterns) {
231
+
if (pattern.test(domainLower)) {
232
+
throw new Error('Invalid domain: IP addresses not allowed');
233
+
}
176
234
}
177
235
178
236
// Check if already exists