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