javascript-solid-server 0.0.51 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY-AUDIT-2026-01-15.md +514 -0
- package/package.json +1 -1
- package/src/auth/nostr.js +11 -0
- package/src/handlers/container.js +12 -0
- package/src/handlers/resource.js +6 -6
- package/src/idp/interactions.js +19 -0
- package/src/notifications/websocket.js +17 -0
- package/src/rdf/conneg.js +2 -1
- package/src/server.js +20 -5
- package/src/storage/filesystem.js +7 -1
- package/src/utils/url.js +54 -7
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# JSS Deep Security Audit Report
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-01-15
|
|
4
|
+
**Auditor:** Comprehensive Security Review
|
|
5
|
+
**Version Audited:** 0.0.51
|
|
6
|
+
**Previous Audit:** 2026-01-03 (v0.0.48)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Executive Summary
|
|
11
|
+
|
|
12
|
+
A comprehensive security audit of JavaScriptSolidServer (v0.0.51) revealed **1 critical**, **3 high**, **5 medium**, and **4 low** severity vulnerabilities. While previous critical issues (ACL bypass, JWT signature verification, SSRF) have been fixed, new vulnerabilities were discovered in path traversal protection, input validation, and denial-of-service resistance.
|
|
13
|
+
|
|
14
|
+
**Overall Security Posture:** 🟡 **Moderate Risk** - Critical path traversal vulnerability requires immediate attention.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Critical Vulnerabilities
|
|
19
|
+
|
|
20
|
+
### 1. Path Traversal Vulnerability in `urlToPath` (CRITICAL) ⚠️
|
|
21
|
+
|
|
22
|
+
**Location:** `src/utils/url.js:23-32`
|
|
23
|
+
|
|
24
|
+
**Description:** The path traversal protection in `urlToPath()` and `urlToPathWithPod()` is insufficient and can be bypassed using multiple techniques.
|
|
25
|
+
|
|
26
|
+
**Vulnerable Code:**
|
|
27
|
+
|
|
28
|
+
```javascript
|
|
29
|
+
export function urlToPath(urlPath) {
|
|
30
|
+
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath
|
|
31
|
+
normalized = decodeURIComponent(normalized)
|
|
32
|
+
|
|
33
|
+
// Security: prevent path traversal
|
|
34
|
+
normalized = normalized.replace(/\.\./g, '') // ❌ INSUFFICIENT
|
|
35
|
+
|
|
36
|
+
return path.join(getDataRoot(), normalized)
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Attack Vectors:**
|
|
41
|
+
|
|
42
|
+
1. **Double-dot bypass:** `....//` → after replacement becomes `../`
|
|
43
|
+
2. **URL encoding bypass:** `%2e%2e%2f` → decodes to `../` after replacement
|
|
44
|
+
3. **Mixed encoding:** `%2e.` → decodes to `..` after replacement
|
|
45
|
+
4. **Path separator injection:** `path.join()` doesn't prevent traversal if normalized still contains `/` or `\`
|
|
46
|
+
|
|
47
|
+
**Proof of Concept:**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Bypass 1: Double-dot
|
|
51
|
+
GET /alice/....//etc/passwd
|
|
52
|
+
|
|
53
|
+
# Bypass 2: URL encoding
|
|
54
|
+
GET /alice/%2e%2e%2f%2e%2e%2fetc%2fpasswd
|
|
55
|
+
|
|
56
|
+
# Bypass 3: Mixed
|
|
57
|
+
GET /alice/%2e./%2e./etc/passwd
|
|
58
|
+
|
|
59
|
+
# Bypass 4: Windows path separator
|
|
60
|
+
GET /alice/..\..\etc\passwd
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Impact:**
|
|
64
|
+
|
|
65
|
+
- Read arbitrary files outside DATA_ROOT
|
|
66
|
+
- Write arbitrary files (if write permission exists)
|
|
67
|
+
- Access other pods' data in multi-user mode
|
|
68
|
+
- Potential remote code execution if sensitive config files are overwritten
|
|
69
|
+
|
|
70
|
+
**CVSS Score:** 9.1 (Critical)
|
|
71
|
+
|
|
72
|
+
**Fix Required:**
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
export function urlToPath(urlPath) {
|
|
76
|
+
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath
|
|
77
|
+
normalized = decodeURIComponent(normalized)
|
|
78
|
+
|
|
79
|
+
// Remove all path traversal attempts (multiple passes)
|
|
80
|
+
normalized = normalized.replace(/\.\./g, '')
|
|
81
|
+
normalized = normalized.replace(/\.\./g, '') // Second pass for ....//
|
|
82
|
+
|
|
83
|
+
// Resolve to absolute path and check it's within DATA_ROOT
|
|
84
|
+
const resolved = path.resolve(getDataRoot(), normalized)
|
|
85
|
+
const dataRoot = path.resolve(getDataRoot())
|
|
86
|
+
|
|
87
|
+
if (!resolved.startsWith(dataRoot + path.sep) && resolved !== dataRoot) {
|
|
88
|
+
throw new Error('Path traversal detected')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return resolved
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## High Severity Vulnerabilities
|
|
98
|
+
|
|
99
|
+
### 2. JSON.parse DoS via Malicious Input (HIGH)
|
|
100
|
+
|
|
101
|
+
**Location:** Multiple files (17 instances found)
|
|
102
|
+
|
|
103
|
+
**Description:** `JSON.parse()` is called on user-controlled input without size limits or try-catch in several locations, allowing denial-of-service attacks via:
|
|
104
|
+
|
|
105
|
+
- Deeply nested JSON structures
|
|
106
|
+
- Large JSON payloads
|
|
107
|
+
- Malformed JSON causing parser hangs
|
|
108
|
+
|
|
109
|
+
**Vulnerable Locations:**
|
|
110
|
+
|
|
111
|
+
- `src/handlers/resource.js:89, 271, 294, 653, 670` - HTML data island extraction
|
|
112
|
+
- `src/auth/nostr.js:87` - Nostr event decoding
|
|
113
|
+
- `src/idp/interactions.js:62, 75, 321` - Form body parsing
|
|
114
|
+
- `src/rdf/conneg.js:140` - Content negotiation
|
|
115
|
+
- `src/wac/parser.js:40` - ACL parsing
|
|
116
|
+
|
|
117
|
+
**Impact:**
|
|
118
|
+
|
|
119
|
+
- Server resource exhaustion (CPU, memory)
|
|
120
|
+
- Request timeouts
|
|
121
|
+
- Potential service unavailability
|
|
122
|
+
|
|
123
|
+
**CVSS Score:** 7.5 (High)
|
|
124
|
+
|
|
125
|
+
**Fix Required:** Add size limits and proper error handling:
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
// Example fix for resource.js
|
|
129
|
+
try {
|
|
130
|
+
const maxSize = 10 * 1024 * 1024 // 10MB limit
|
|
131
|
+
if (jsonLdMatch[1].length > maxSize) {
|
|
132
|
+
throw new Error('JSON-LD data island too large')
|
|
133
|
+
}
|
|
134
|
+
const jsonLd = JSON.parse(jsonLdMatch[1])
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e instanceof SyntaxError) {
|
|
137
|
+
return reply.code(400).send({ error: 'Invalid JSON-LD' })
|
|
138
|
+
}
|
|
139
|
+
throw e
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### 3. SPARQL Update Injection Risk (HIGH)
|
|
146
|
+
|
|
147
|
+
**Location:** `src/patch/sparql-update.js:22-85`
|
|
148
|
+
|
|
149
|
+
**Description:** The SPARQL Update parser uses regex-based parsing with fallback to simple pattern matching. This can be exploited to:
|
|
150
|
+
|
|
151
|
+
- Inject malicious SPARQL constructs
|
|
152
|
+
- Bypass intended DELETE/INSERT operations
|
|
153
|
+
- Cause parser errors leading to information disclosure
|
|
154
|
+
|
|
155
|
+
**Vulnerable Code:**
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
// Regex-based parsing without proper validation
|
|
159
|
+
const insertDataMatch = query.match(/INSERT\s+DATA\s*\{([^}]+)\}/is)
|
|
160
|
+
const deleteDataMatch = query.match(/DELETE\s+DATA\s*\{([^}]+)\}/is)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Attack Vector:**
|
|
164
|
+
|
|
165
|
+
```sparql
|
|
166
|
+
INSERT DATA {
|
|
167
|
+
<#malicious> <predicate> "value" .
|
|
168
|
+
} INSERT DATA {
|
|
169
|
+
<#legitimate> <predicate> "value" .
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Impact:**
|
|
174
|
+
|
|
175
|
+
- Unauthorized data modification
|
|
176
|
+
- Data corruption
|
|
177
|
+
- ACL bypass through unexpected triple insertions
|
|
178
|
+
|
|
179
|
+
**CVSS Score:** 8.1 (High)
|
|
180
|
+
|
|
181
|
+
**Fix Required:** Use proper SPARQL parser library or implement strict validation.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### 4. WebSocket DoS via Subscription Spam (HIGH)
|
|
186
|
+
|
|
187
|
+
**Location:** `src/notifications/websocket.js:34-53`
|
|
188
|
+
|
|
189
|
+
**Description:** WebSocket connections can subscribe to unlimited URLs without rate limiting, allowing:
|
|
190
|
+
|
|
191
|
+
- Memory exhaustion via subscription spam
|
|
192
|
+
- CPU exhaustion via broadcast storms
|
|
193
|
+
- Resource exhaustion attacks
|
|
194
|
+
|
|
195
|
+
**Vulnerable Code:**
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
socket.on('message', message => {
|
|
199
|
+
const msg = message.toString().trim()
|
|
200
|
+
if (msg.startsWith('sub ')) {
|
|
201
|
+
const url = msg.slice(4).trim()
|
|
202
|
+
if (url) {
|
|
203
|
+
subscribe(socket, url) // ❌ No limits
|
|
204
|
+
socket.send(`ack ${url}`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Impact:**
|
|
211
|
+
|
|
212
|
+
- Server memory exhaustion
|
|
213
|
+
- CPU exhaustion during broadcasts
|
|
214
|
+
- Service unavailability
|
|
215
|
+
|
|
216
|
+
**CVSS Score:** 7.5 (High)
|
|
217
|
+
|
|
218
|
+
**Fix Required:** Implement per-connection subscription limits:
|
|
219
|
+
|
|
220
|
+
```javascript
|
|
221
|
+
const MAX_SUBSCRIPTIONS_PER_CONNECTION = 100
|
|
222
|
+
const socketSubs = subscriptions.get(socket)
|
|
223
|
+
if (socketSubs && socketSubs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) {
|
|
224
|
+
socket.send('error: Subscription limit exceeded')
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Medium Severity Vulnerabilities
|
|
232
|
+
|
|
233
|
+
### 5. Insufficient Rate Limiting Coverage (MEDIUM)
|
|
234
|
+
|
|
235
|
+
**Location:** `src/server.js:131-143`
|
|
236
|
+
|
|
237
|
+
**Description:** Rate limiting is configured but not applied to all sensitive endpoints:
|
|
238
|
+
|
|
239
|
+
- PATCH operations (can be expensive)
|
|
240
|
+
- DELETE operations (destructive)
|
|
241
|
+
- WebSocket connections (no rate limit)
|
|
242
|
+
- OIDC token endpoint (credential stuffing)
|
|
243
|
+
|
|
244
|
+
**Impact:**
|
|
245
|
+
|
|
246
|
+
- Brute force attacks on authentication
|
|
247
|
+
- Resource exhaustion via rapid requests
|
|
248
|
+
- DoS via expensive operations
|
|
249
|
+
|
|
250
|
+
**CVSS Score:** 5.3 (Medium)
|
|
251
|
+
|
|
252
|
+
**Fix Required:** Apply rate limiting to all write operations and authentication endpoints.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
### 6. Information Disclosure via Error Messages (MEDIUM)
|
|
257
|
+
|
|
258
|
+
**Location:** Multiple handlers
|
|
259
|
+
|
|
260
|
+
**Description:** Error messages may reveal:
|
|
261
|
+
|
|
262
|
+
- Internal file paths
|
|
263
|
+
- Stack traces in development mode
|
|
264
|
+
- Database structure hints
|
|
265
|
+
- System architecture details
|
|
266
|
+
|
|
267
|
+
**Examples:**
|
|
268
|
+
|
|
269
|
+
- `src/handlers/resource.js:502` - Turtle parsing errors expose internal paths
|
|
270
|
+
- `src/patch/sparql-update.js:696` - SPARQL errors reveal query structure
|
|
271
|
+
|
|
272
|
+
**Impact:**
|
|
273
|
+
|
|
274
|
+
- Information useful for further attacks
|
|
275
|
+
- System fingerprinting
|
|
276
|
+
- Path enumeration
|
|
277
|
+
|
|
278
|
+
**CVSS Score:** 4.3 (Medium)
|
|
279
|
+
|
|
280
|
+
**Fix Required:** Sanitize error messages in production:
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
if (process.env.NODE_ENV === 'production') {
|
|
284
|
+
return reply.code(400).send({ error: 'Invalid request' })
|
|
285
|
+
} else {
|
|
286
|
+
return reply
|
|
287
|
+
.code(400)
|
|
288
|
+
.send({ error: 'Invalid request', details: err.message })
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
### 7. Missing Input Validation on Slug Header (MEDIUM)
|
|
295
|
+
|
|
296
|
+
**Location:** `src/handlers/container.js:55, 62`
|
|
297
|
+
|
|
298
|
+
**Description:** The `Slug` header in POST requests is sanitized but not fully validated:
|
|
299
|
+
|
|
300
|
+
- No length limit
|
|
301
|
+
- No character set validation beyond path traversal
|
|
302
|
+
- Can create files with problematic names
|
|
303
|
+
|
|
304
|
+
**Vulnerable Code:**
|
|
305
|
+
|
|
306
|
+
```javascript
|
|
307
|
+
const slug = request.headers.slug
|
|
308
|
+
const filename = await storage.generateUniqueFilename(
|
|
309
|
+
storagePath,
|
|
310
|
+
slug,
|
|
311
|
+
isCreatingContainer
|
|
312
|
+
)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Impact:**
|
|
316
|
+
|
|
317
|
+
- Filesystem issues with special characters
|
|
318
|
+
- Potential path confusion attacks
|
|
319
|
+
- Unicode normalization issues
|
|
320
|
+
|
|
321
|
+
**CVSS Score:** 4.9 (Medium)
|
|
322
|
+
|
|
323
|
+
**Fix Required:** Add strict validation:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
if (slug && slug.length > 255) {
|
|
327
|
+
return reply.code(400).send({ error: 'Slug too long' })
|
|
328
|
+
}
|
|
329
|
+
if (slug && !/^[a-zA-Z0-9._-]+$/.test(slug)) {
|
|
330
|
+
return reply.code(400).send({ error: 'Invalid slug format' })
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
### 8. RDF Parsing DoS via Billion Laughs Attack (MEDIUM)
|
|
337
|
+
|
|
338
|
+
**Location:** `src/rdf/turtle.js:32-56`, `src/rdf/conneg.js`
|
|
339
|
+
|
|
340
|
+
**Description:** Turtle/N3 parsing doesn't limit:
|
|
341
|
+
|
|
342
|
+
- Quad count
|
|
343
|
+
- Prefix expansion depth
|
|
344
|
+
- Recursive entity references
|
|
345
|
+
|
|
346
|
+
**Impact:**
|
|
347
|
+
|
|
348
|
+
- Memory exhaustion via large RDF documents
|
|
349
|
+
- CPU exhaustion via complex parsing
|
|
350
|
+
- Service unavailability
|
|
351
|
+
|
|
352
|
+
**CVSS Score:** 5.3 (Medium)
|
|
353
|
+
|
|
354
|
+
**Fix Required:** Add parsing limits:
|
|
355
|
+
|
|
356
|
+
```javascript
|
|
357
|
+
const MAX_QUADS = 100000
|
|
358
|
+
const MAX_PREFIXES = 1000
|
|
359
|
+
// Enforce limits during parsing
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
### 9. Missing CSRF Protection on State-Changing Operations (MEDIUM)
|
|
365
|
+
|
|
366
|
+
**Location:** All PUT/PATCH/DELETE handlers
|
|
367
|
+
|
|
368
|
+
**Description:** No CSRF tokens or SameSite cookie protection for state-changing operations. While Solid-OIDC DPoP tokens provide some protection, simple Bearer tokens are vulnerable.
|
|
369
|
+
|
|
370
|
+
**Impact:**
|
|
371
|
+
|
|
372
|
+
- Cross-site request forgery
|
|
373
|
+
- Unauthorized data modification
|
|
374
|
+
- Account takeover (if combined with XSS)
|
|
375
|
+
|
|
376
|
+
**CVSS Score:** 6.1 (Medium)
|
|
377
|
+
|
|
378
|
+
**Fix Required:** Implement CSRF protection for non-DPoP tokens or require SameSite cookies.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Low Severity Vulnerabilities
|
|
383
|
+
|
|
384
|
+
### 10. Weak Path Traversal Protection in `generateUniqueFilename` (LOW)
|
|
385
|
+
|
|
386
|
+
**Location:** `src/storage/filesystem.js:132-150`
|
|
387
|
+
|
|
388
|
+
**Description:** Only removes `/` and `\` but doesn't validate the final path is within bounds.
|
|
389
|
+
|
|
390
|
+
**Impact:** Limited - only affects POST slug generation, not direct path access.
|
|
391
|
+
|
|
392
|
+
**CVSS Score:** 3.1 (Low)
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
### 11. Missing Content-Length Validation (LOW)
|
|
397
|
+
|
|
398
|
+
**Location:** Request body handlers
|
|
399
|
+
|
|
400
|
+
**Description:** `bodyLimit` is set to 10MB but no per-request validation of Content-Length header.
|
|
401
|
+
|
|
402
|
+
**Impact:** Potential for request smuggling or resource exhaustion.
|
|
403
|
+
|
|
404
|
+
**CVSS Score:** 3.5 (Low)
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
### 12. Insufficient Logging of Security Events (LOW)
|
|
409
|
+
|
|
410
|
+
**Location:** Authentication and authorization handlers
|
|
411
|
+
|
|
412
|
+
**Description:** Failed authentication attempts are logged but not rate-limited or blocked after repeated failures.
|
|
413
|
+
|
|
414
|
+
**Impact:** Difficulty detecting brute force attacks.
|
|
415
|
+
|
|
416
|
+
**CVSS Score:** 2.5 (Low)
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### 13. CORS Headers Allow All Origins (LOW)
|
|
421
|
+
|
|
422
|
+
**Location:** `src/ldp/headers.js`
|
|
423
|
+
|
|
424
|
+
**Description:** CORS headers allow requests from any origin (`*`). While necessary for Solid interoperability, this increases XSS risk.
|
|
425
|
+
|
|
426
|
+
**Impact:** Increased XSS attack surface.
|
|
427
|
+
|
|
428
|
+
**CVSS Score:** 3.1 (Low)
|
|
429
|
+
|
|
430
|
+
**Note:** This may be intentional for Solid protocol compliance.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Positive Security Findings
|
|
435
|
+
|
|
436
|
+
✅ **Fixed Issues from Previous Audit:**
|
|
437
|
+
|
|
438
|
+
- ACL bypass (v0.0.49) - Now requires Control permission
|
|
439
|
+
- JWT signature verification (v0.0.49) - Properly verifies against JWKS
|
|
440
|
+
- SSRF protection (v0.0.50) - URL validation implemented
|
|
441
|
+
- Pod creation abuse (v0.0.51) - Rate limited
|
|
442
|
+
- Default token secret (v0.0.51) - Fails in production if not set
|
|
443
|
+
- Rate limiting (v0.0.51) - Basic rate limiting added
|
|
444
|
+
|
|
445
|
+
✅ **Good Security Practices:**
|
|
446
|
+
|
|
447
|
+
- Constant-time comparison for HMAC verification
|
|
448
|
+
- Proper bcrypt usage for password hashing
|
|
449
|
+
- DPoP proof validation with timestamp checks
|
|
450
|
+
- SSRF protection with DNS rebinding prevention
|
|
451
|
+
- Path traversal attempt in `generateUniqueFilename`
|
|
452
|
+
- WAC authorization properly implemented
|
|
453
|
+
- No `eval()` or `Function()` usage found
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Recommendations
|
|
458
|
+
|
|
459
|
+
### Immediate Actions (Critical/High)
|
|
460
|
+
|
|
461
|
+
1. **Fix path traversal vulnerability** - Implement proper path resolution and validation
|
|
462
|
+
2. **Add JSON.parse size limits** - Prevent DoS via malicious JSON
|
|
463
|
+
3. **Harden SPARQL parser** - Use proper parser or strict validation
|
|
464
|
+
4. **Limit WebSocket subscriptions** - Prevent memory exhaustion
|
|
465
|
+
|
|
466
|
+
### Short-term Actions (Medium)
|
|
467
|
+
|
|
468
|
+
5. **Expand rate limiting** - Cover all write operations and auth endpoints
|
|
469
|
+
6. **Sanitize error messages** - Remove internal details in production
|
|
470
|
+
7. **Validate Slug header** - Add length and character restrictions
|
|
471
|
+
8. **Add RDF parsing limits** - Prevent Billion Laughs attacks
|
|
472
|
+
|
|
473
|
+
### Long-term Actions (Low)
|
|
474
|
+
|
|
475
|
+
9. **Implement CSRF protection** - For non-DPoP authentication
|
|
476
|
+
10. **Enhanced security logging** - Track and alert on suspicious patterns
|
|
477
|
+
11. **Security headers** - Add HSTS, CSP, X-Frame-Options where appropriate
|
|
478
|
+
12. **Regular security audits** - Schedule quarterly reviews
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Testing Recommendations
|
|
483
|
+
|
|
484
|
+
1. **Fuzz testing** - Path traversal, JSON parsing, SPARQL parsing
|
|
485
|
+
2. **Load testing** - WebSocket subscriptions, concurrent requests
|
|
486
|
+
3. **Penetration testing** - Full security assessment
|
|
487
|
+
4. **Dependency scanning** - Check for vulnerable npm packages
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Remediation Priority
|
|
492
|
+
|
|
493
|
+
| Priority | Issue | Severity | Effort | Target Version |
|
|
494
|
+
| -------- | ------------------ | -------- | ------ | -------------- |
|
|
495
|
+
| P0 | Path traversal | Critical | Medium | v0.0.52 |
|
|
496
|
+
| P1 | JSON.parse DoS | High | Low | v0.0.52 |
|
|
497
|
+
| P1 | WebSocket DoS | High | Low | v0.0.52 |
|
|
498
|
+
| P2 | SPARQL injection | High | High | v0.0.53 |
|
|
499
|
+
| P2 | Rate limiting gaps | Medium | Medium | v0.0.53 |
|
|
500
|
+
| P3 | Error disclosure | Medium | Low | v0.0.54 |
|
|
501
|
+
| P3 | Input validation | Medium | Low | v0.0.54 |
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Conclusion
|
|
506
|
+
|
|
507
|
+
While significant security improvements have been made since the previous audit, the path traversal vulnerability requires immediate attention. The codebase shows good security awareness with proper authentication, authorization, and SSRF protection. However, input validation and DoS resistance need strengthening.
|
|
508
|
+
|
|
509
|
+
**Overall Assessment:** The server is suitable for production use after fixing the critical path traversal issue and implementing the high-priority recommendations.
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
_Report generated: 2026-01-15_
|
|
514
|
+
_Next audit recommended: 2026-04-15_
|
package/package.json
CHANGED
package/src/auth/nostr.js
CHANGED
|
@@ -76,6 +76,9 @@ export function extractNostrToken(authHeader) {
|
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Maximum size for Nostr event (64KB should be plenty for auth events)
|
|
80
|
+
const MAX_NOSTR_EVENT_SIZE = 64 * 1024;
|
|
81
|
+
|
|
79
82
|
/**
|
|
80
83
|
* Decode NIP-98 event from base64 token
|
|
81
84
|
* @param {string} token - Base64 encoded event
|
|
@@ -83,7 +86,15 @@ export function extractNostrToken(authHeader) {
|
|
|
83
86
|
*/
|
|
84
87
|
function decodeEvent(token) {
|
|
85
88
|
try {
|
|
89
|
+
// Security: limit token size before decoding
|
|
90
|
+
if (token.length > MAX_NOSTR_EVENT_SIZE) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
86
93
|
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
94
|
+
// Security: limit decoded size before parsing
|
|
95
|
+
if (decoded.length > MAX_NOSTR_EVENT_SIZE) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
87
98
|
return JSON.parse(decoded);
|
|
88
99
|
} catch {
|
|
89
100
|
return null;
|
|
@@ -55,6 +55,18 @@ export async function handlePost(request, reply) {
|
|
|
55
55
|
const slug = request.headers.slug;
|
|
56
56
|
const linkHeader = request.headers.link || '';
|
|
57
57
|
|
|
58
|
+
// Security: validate Slug header
|
|
59
|
+
if (slug) {
|
|
60
|
+
// Maximum length check
|
|
61
|
+
if (slug.length > 255) {
|
|
62
|
+
return reply.code(400).send({ error: 'Slug header too long (max 255 characters)' });
|
|
63
|
+
}
|
|
64
|
+
// Character validation - allow alphanumeric, dots, dashes, underscores
|
|
65
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
|
|
66
|
+
return reply.code(400).send({ error: 'Invalid Slug format. Use only alphanumeric characters, dots, dashes, and underscores.' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// Check if creating a container (Link header contains ldp:Container or ldp:BasicContainer)
|
|
59
71
|
const isCreatingContainer = linkHeader.includes('Container') || linkHeader.includes('BasicContainer');
|
|
60
72
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
2
|
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
|
-
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath } from '../utils/url.js';
|
|
4
|
+
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse } from '../utils/url.js';
|
|
5
5
|
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
6
6
|
import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
|
|
7
7
|
import {
|
|
@@ -86,7 +86,7 @@ export async function handleGet(request, reply) {
|
|
|
86
86
|
const htmlStr = content.toString();
|
|
87
87
|
const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/);
|
|
88
88
|
if (jsonLdMatch) {
|
|
89
|
-
const jsonLd =
|
|
89
|
+
const jsonLd = safeJsonParse(jsonLdMatch[1]);
|
|
90
90
|
|
|
91
91
|
if (wantsTurtle) {
|
|
92
92
|
// Convert to Turtle
|
|
@@ -268,7 +268,7 @@ export async function handleGet(request, reply) {
|
|
|
268
268
|
try {
|
|
269
269
|
const jsonLdMatch = contentStr.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
270
270
|
if (jsonLdMatch) {
|
|
271
|
-
const jsonLd =
|
|
271
|
+
const jsonLd = safeJsonParse(jsonLdMatch[1]);
|
|
272
272
|
const { content: turtleContent } = await fromJsonLd(jsonLd, 'text/turtle', resourceUrl, true);
|
|
273
273
|
|
|
274
274
|
const headers = getAllHeaders({
|
|
@@ -291,7 +291,7 @@ export async function handleGet(request, reply) {
|
|
|
291
291
|
} else if (isRdfContentType(storedContentType)) {
|
|
292
292
|
// Plain JSON-LD file
|
|
293
293
|
try {
|
|
294
|
-
const jsonLd =
|
|
294
|
+
const jsonLd = safeJsonParse(contentStr);
|
|
295
295
|
// Use Turtle if URL ends with .ttl, otherwise use Accept header preference
|
|
296
296
|
const targetType = wantsTurtle ? 'text/turtle' : selectContentType(acceptHeader, connegEnabled);
|
|
297
297
|
const { content: outputContent, contentType: outputType } = await fromJsonLd(
|
|
@@ -650,7 +650,7 @@ export async function handlePatch(request, reply) {
|
|
|
650
650
|
}
|
|
651
651
|
|
|
652
652
|
try {
|
|
653
|
-
document =
|
|
653
|
+
document = safeJsonParse(jsonLdMatch[1]);
|
|
654
654
|
// Save the HTML parts for re-embedding after patch
|
|
655
655
|
const jsonLdStart = contentStr.indexOf(jsonLdMatch[0]) + jsonLdMatch[0].indexOf('>') + 1;
|
|
656
656
|
const jsonLdEnd = jsonLdStart + jsonLdMatch[1].length;
|
|
@@ -667,7 +667,7 @@ export async function handlePatch(request, reply) {
|
|
|
667
667
|
} else {
|
|
668
668
|
// Try to parse as JSON-LD first
|
|
669
669
|
try {
|
|
670
|
-
document =
|
|
670
|
+
document = safeJsonParse(contentStr);
|
|
671
671
|
} catch (e) {
|
|
672
672
|
// Not JSON - might be Turtle, handle with RDF store for SPARQL Update
|
|
673
673
|
if (isSparqlUpdate) {
|
package/src/idp/interactions.js
CHANGED
|
@@ -8,6 +8,9 @@ import { loginPage, consentPage, errorPage, registerPage } from './views.js';
|
|
|
8
8
|
import * as storage from '../storage/filesystem.js';
|
|
9
9
|
import { createPodStructure } from '../handlers/container.js';
|
|
10
10
|
|
|
11
|
+
// Security: Maximum body size for IdP form submissions (1MB)
|
|
12
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Handle GET /idp/interaction/:uid
|
|
13
16
|
* Shows login or consent page based on interaction state
|
|
@@ -56,6 +59,10 @@ export async function handleLogin(request, reply, provider) {
|
|
|
56
59
|
const contentType = request.headers['content-type'] || '';
|
|
57
60
|
|
|
58
61
|
if (Buffer.isBuffer(parsedBody)) {
|
|
62
|
+
// Security: check body size
|
|
63
|
+
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
64
|
+
return reply.code(413).type('text/html').send(errorPage('Request Too Large', 'Request body exceeds maximum size.'));
|
|
65
|
+
}
|
|
59
66
|
const bodyStr = parsedBody.toString();
|
|
60
67
|
if (contentType.includes('application/json')) {
|
|
61
68
|
try {
|
|
@@ -69,6 +76,10 @@ export async function handleLogin(request, reply, provider) {
|
|
|
69
76
|
parsedBody = Object.fromEntries(params.entries());
|
|
70
77
|
}
|
|
71
78
|
} else if (typeof parsedBody === 'string') {
|
|
79
|
+
// Security: check body size
|
|
80
|
+
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
81
|
+
return reply.code(413).type('text/html').send(errorPage('Request Too Large', 'Request body exceeds maximum size.'));
|
|
82
|
+
}
|
|
72
83
|
// Body might be a string for form-urlencoded
|
|
73
84
|
if (contentType.includes('application/json')) {
|
|
74
85
|
try {
|
|
@@ -315,6 +326,10 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
315
326
|
const contentType = request.headers['content-type'] || '';
|
|
316
327
|
|
|
317
328
|
if (Buffer.isBuffer(parsedBody)) {
|
|
329
|
+
// Security: check body size
|
|
330
|
+
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
331
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
|
|
332
|
+
}
|
|
318
333
|
const bodyStr = parsedBody.toString();
|
|
319
334
|
if (contentType.includes('application/json')) {
|
|
320
335
|
try {
|
|
@@ -327,6 +342,10 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
327
342
|
parsedBody = Object.fromEntries(params.entries());
|
|
328
343
|
}
|
|
329
344
|
} else if (typeof parsedBody === 'string') {
|
|
345
|
+
// Security: check body size
|
|
346
|
+
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
347
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
|
|
348
|
+
}
|
|
330
349
|
const params = new URLSearchParams(parsedBody);
|
|
331
350
|
parsedBody = Object.fromEntries(params.entries());
|
|
332
351
|
}
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { resourceEvents } from './events.js';
|
|
14
14
|
|
|
15
|
+
// Security limits
|
|
16
|
+
const MAX_SUBSCRIPTIONS_PER_CONNECTION = 100;
|
|
17
|
+
const MAX_URL_LENGTH = 2048;
|
|
18
|
+
|
|
15
19
|
// Track subscriptions: WebSocket -> Set<url>
|
|
16
20
|
const subscriptions = new Map();
|
|
17
21
|
|
|
@@ -38,6 +42,19 @@ export function handleWebSocket(socket, request) {
|
|
|
38
42
|
if (msg.startsWith('sub ')) {
|
|
39
43
|
const url = msg.slice(4).trim();
|
|
40
44
|
if (url) {
|
|
45
|
+
// Security: validate URL length
|
|
46
|
+
if (url.length > MAX_URL_LENGTH) {
|
|
47
|
+
socket.send('error: URL too long');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Security: check subscription limit
|
|
52
|
+
const socketSubs = subscriptions.get(socket);
|
|
53
|
+
if (socketSubs && socketSubs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) {
|
|
54
|
+
socket.send('error: Subscription limit exceeded');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
subscribe(socket, url);
|
|
42
59
|
socket.send(`ack ${url}`);
|
|
43
60
|
}
|
package/src/rdf/conneg.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { turtleToJsonLd, jsonLdToTurtle } from './turtle.js';
|
|
10
|
+
import { safeJsonParse } from '../utils/url.js';
|
|
10
11
|
|
|
11
12
|
// RDF content types we support
|
|
12
13
|
export const RDF_TYPES = {
|
|
@@ -137,7 +138,7 @@ export async function toJsonLd(content, contentType, baseUri, connegEnabled = fa
|
|
|
137
138
|
|
|
138
139
|
// JSON-LD or JSON
|
|
139
140
|
if (type === RDF_TYPES.JSON_LD || type === 'application/json' || !type) {
|
|
140
|
-
return
|
|
141
|
+
return safeJsonParse(text);
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
// Turtle/N3 - only if conneg enabled
|
package/src/server.js
CHANGED
|
@@ -290,20 +290,35 @@ export function createServer(options = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// Rate limit configuration for write operations
|
|
294
|
+
// Protects against resource exhaustion and abuse
|
|
295
|
+
const writeRateLimit = {
|
|
296
|
+
config: {
|
|
297
|
+
rateLimit: {
|
|
298
|
+
max: 60,
|
|
299
|
+
timeWindow: '1 minute',
|
|
300
|
+
keyGenerator: (request) => request.webId || request.ip
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
293
305
|
// LDP routes - using wildcard routing
|
|
306
|
+
// Read operations - no rate limit (handled by bodyLimit)
|
|
294
307
|
fastify.get('/*', handleGet);
|
|
295
308
|
fastify.head('/*', handleHead);
|
|
296
|
-
fastify.put('/*', handlePut);
|
|
297
|
-
fastify.delete('/*', handleDelete);
|
|
298
|
-
fastify.post('/*', handlePost);
|
|
299
|
-
fastify.patch('/*', handlePatch);
|
|
300
309
|
fastify.options('/*', handleOptions);
|
|
301
310
|
|
|
311
|
+
// Write operations - rate limited
|
|
312
|
+
fastify.put('/*', writeRateLimit, handlePut);
|
|
313
|
+
fastify.delete('/*', writeRateLimit, handleDelete);
|
|
314
|
+
fastify.post('/*', writeRateLimit, handlePost);
|
|
315
|
+
fastify.patch('/*', writeRateLimit, handlePatch);
|
|
316
|
+
|
|
302
317
|
// Root route
|
|
303
318
|
fastify.get('/', handleGet);
|
|
304
319
|
fastify.head('/', handleHead);
|
|
305
320
|
fastify.options('/', handleOptions);
|
|
306
|
-
fastify.post('/', handlePost);
|
|
321
|
+
fastify.post('/', writeRateLimit, handlePost);
|
|
307
322
|
|
|
308
323
|
return fastify;
|
|
309
324
|
}
|
|
@@ -133,8 +133,14 @@ export async function generateUniqueFilename(containerPath, slug, isDir = false)
|
|
|
133
133
|
const basePath = urlToPath(containerPath);
|
|
134
134
|
let name = slug || crypto.randomUUID();
|
|
135
135
|
|
|
136
|
-
// Remove any path traversal attempts
|
|
136
|
+
// Security: Remove any path traversal attempts and problematic characters
|
|
137
137
|
name = name.replace(/[/\\]/g, '-');
|
|
138
|
+
name = name.replace(/\.\./g, ''); // Remove .. sequences
|
|
139
|
+
|
|
140
|
+
// Security: Limit filename length
|
|
141
|
+
if (name.length > 255) {
|
|
142
|
+
name = name.substring(0, 255);
|
|
143
|
+
}
|
|
138
144
|
|
|
139
145
|
let candidate = path.join(basePath, name);
|
|
140
146
|
let counter = 1;
|
package/src/utils/url.js
CHANGED
|
@@ -19,16 +19,30 @@ export function updateDataRoot() {
|
|
|
19
19
|
* Convert URL path to filesystem path
|
|
20
20
|
* @param {string} urlPath - The URL path (e.g., /alice/profile/)
|
|
21
21
|
* @returns {string} - Filesystem path
|
|
22
|
+
* @throws {Error} - If path traversal is detected
|
|
22
23
|
*/
|
|
23
24
|
export function urlToPath(urlPath) {
|
|
24
25
|
// Normalize: remove leading slash, decode URI
|
|
25
26
|
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
|
|
26
27
|
normalized = decodeURIComponent(normalized);
|
|
27
28
|
|
|
28
|
-
// Security:
|
|
29
|
-
|
|
29
|
+
// Security: remove path traversal attempts (multiple passes for ....// bypass)
|
|
30
|
+
let previous;
|
|
31
|
+
do {
|
|
32
|
+
previous = normalized;
|
|
33
|
+
normalized = normalized.replace(/\.\./g, '');
|
|
34
|
+
} while (normalized !== previous);
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// Resolve to absolute path and verify it's within DATA_ROOT
|
|
37
|
+
const dataRoot = path.resolve(getDataRoot());
|
|
38
|
+
const resolved = path.resolve(dataRoot, normalized);
|
|
39
|
+
|
|
40
|
+
// Ensure resolved path is within dataRoot (prevent traversal via path.resolve tricks)
|
|
41
|
+
if (!resolved.startsWith(dataRoot + path.sep) && resolved !== dataRoot) {
|
|
42
|
+
throw new Error('Path traversal detected');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return resolved;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/**
|
|
@@ -37,17 +51,33 @@ export function urlToPath(urlPath) {
|
|
|
37
51
|
* @param {string} urlPath - The URL path (e.g., /public/file.txt)
|
|
38
52
|
* @param {string} podName - The pod name from subdomain (e.g., "alice")
|
|
39
53
|
* @returns {string} - Filesystem path (e.g., DATA_ROOT/alice/public/file.txt)
|
|
54
|
+
* @throws {Error} - If path traversal is detected
|
|
40
55
|
*/
|
|
41
56
|
export function urlToPathWithPod(urlPath, podName) {
|
|
42
57
|
// Normalize: remove leading slash, decode URI
|
|
43
58
|
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
|
|
44
59
|
normalized = decodeURIComponent(normalized);
|
|
45
60
|
|
|
46
|
-
// Security:
|
|
47
|
-
|
|
61
|
+
// Security: remove path traversal attempts (multiple passes for ....// bypass)
|
|
62
|
+
let previous;
|
|
63
|
+
do {
|
|
64
|
+
previous = normalized;
|
|
65
|
+
normalized = normalized.replace(/\.\./g, '');
|
|
66
|
+
} while (normalized !== previous);
|
|
67
|
+
|
|
68
|
+
// Also sanitize podName
|
|
69
|
+
let safePodName = podName.replace(/\.\./g, '');
|
|
70
|
+
|
|
71
|
+
// Resolve to absolute path and verify it's within DATA_ROOT
|
|
72
|
+
const dataRoot = path.resolve(getDataRoot());
|
|
73
|
+
const resolved = path.resolve(dataRoot, safePodName, normalized);
|
|
74
|
+
|
|
75
|
+
// Ensure resolved path is within dataRoot (prevent traversal via path.resolve tricks)
|
|
76
|
+
if (!resolved.startsWith(dataRoot + path.sep) && resolved !== dataRoot) {
|
|
77
|
+
throw new Error('Path traversal detected');
|
|
78
|
+
}
|
|
48
79
|
|
|
49
|
-
|
|
50
|
-
return path.join(getDataRoot(), podName, normalized);
|
|
80
|
+
return resolved;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
/**
|
|
@@ -161,3 +191,20 @@ export function isRdfContentType(contentType) {
|
|
|
161
191
|
];
|
|
162
192
|
return rdfTypes.includes(contentType);
|
|
163
193
|
}
|
|
194
|
+
|
|
195
|
+
// Security: Maximum JSON size for parsing (10MB)
|
|
196
|
+
const MAX_JSON_SIZE = 10 * 1024 * 1024;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Safely parse JSON with size limit to prevent DoS
|
|
200
|
+
* @param {string} jsonString - The JSON string to parse
|
|
201
|
+
* @param {number} maxSize - Maximum allowed size (default 10MB)
|
|
202
|
+
* @returns {object} - Parsed JSON object
|
|
203
|
+
* @throws {Error} - If JSON is too large or invalid
|
|
204
|
+
*/
|
|
205
|
+
export function safeJsonParse(jsonString, maxSize = MAX_JSON_SIZE) {
|
|
206
|
+
if (jsonString.length > maxSize) {
|
|
207
|
+
throw new Error(`JSON exceeds maximum size of ${maxSize} bytes`);
|
|
208
|
+
}
|
|
209
|
+
return JSON.parse(jsonString);
|
|
210
|
+
}
|