javascript-solid-server 0.0.50 → 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.
@@ -193,15 +193,20 @@ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
193
193
  | JWT signature bypass | Critical | 🟢 Fixed | v0.0.49 |
194
194
  | SSRF in OIDC discovery | Critical | 🟢 Fixed | v0.0.50 |
195
195
  | SSRF in client document fetch | Critical | 🟢 Fixed | v0.0.50 |
196
- | Unauthenticated pod creation | High | 🔴 Open | - |
197
- | Default token secret | High | 🔴 Open | - |
198
- | No rate limiting | Medium | 🔴 Open | - |
196
+ | Unauthenticated pod creation | High | 🟢 Fixed | v0.0.51 |
197
+ | Default token secret | High | 🟢 Fixed | v0.0.51 |
198
+ | No rate limiting | Medium | 🟢 Fixed | v0.0.51 |
199
199
  | Information disclosure | Medium | 🔴 Open | - |
200
200
 
201
201
  ---
202
202
 
203
203
  ## Changelog
204
204
 
205
+ ### v0.0.51 (2026-01-03)
206
+ - **Fixed pod creation abuse**: Rate limited to 5 pods per IP per hour
207
+ - **Fixed default token secret**: Production (NODE_ENV=production) now requires TOKEN_SECRET env var
208
+ - **Added rate limiting**: Login endpoints limited to 10 attempts/min, registration to 5/hour
209
+
205
210
  ### v0.0.50 (2026-01-03)
206
211
  - **Fixed SSRF in OIDC discovery**: Issuer URLs are now validated before fetching (HTTPS required, private IPs blocked)
207
212
  - **Fixed SSRF in client document fetch**: Client ID URLs are now validated before fetching
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@fastify/middie": "^8.3.3",
27
+ "@fastify/rate-limit": "^9.1.0",
27
28
  "@fastify/websocket": "^8.3.1",
28
29
  "bcrypt": "^6.0.0",
29
30
  "commander": "^14.0.2",
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;
package/src/auth/token.js CHANGED
@@ -11,8 +11,29 @@ import crypto from 'crypto';
11
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
12
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
13
 
14
- // Secret for signing tokens (in production, use env var)
15
- const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
14
+ // Secret for signing tokens
15
+ // SECURITY: In production, TOKEN_SECRET must be set via environment variable
16
+ const getSecret = () => {
17
+ if (process.env.TOKEN_SECRET) {
18
+ return process.env.TOKEN_SECRET;
19
+ }
20
+
21
+ // In production (NODE_ENV=production), require explicit secret
22
+ if (process.env.NODE_ENV === 'production') {
23
+ console.error('SECURITY ERROR: TOKEN_SECRET environment variable must be set in production');
24
+ console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
25
+ process.exit(1);
26
+ }
27
+
28
+ // In development, generate a random secret per process (tokens won't survive restarts)
29
+ const devSecret = crypto.randomBytes(32).toString('hex');
30
+ console.warn('WARNING: No TOKEN_SECRET set. Using random secret (tokens will not survive restarts).');
31
+ console.warn('Set TOKEN_SECRET environment variable for persistent tokens.');
32
+ return devSecret;
33
+ };
34
+
35
+ // Initialize secret once at module load
36
+ const SECRET = getSecret();
16
37
 
17
38
  /**
18
39
  * Create a simple token for a WebID
@@ -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
 
@@ -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 = JSON.parse(jsonLdMatch[1]);
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 = JSON.parse(jsonLdMatch[1]);
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 = JSON.parse(contentStr);
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 = JSON.parse(jsonLdMatch[1]);
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 = JSON.parse(contentStr);
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/index.js CHANGED
@@ -209,8 +209,16 @@ export async function idpPlugin(fastify, options) {
209
209
  return handleCredentialsInfo(request, reply, issuer);
210
210
  });
211
211
 
212
- // POST credentials - obtain tokens
213
- fastify.post('/idp/credentials', async (request, reply) => {
212
+ // POST credentials - obtain tokens (with rate limiting for brute force protection)
213
+ fastify.post('/idp/credentials', {
214
+ config: {
215
+ rateLimit: {
216
+ max: 10,
217
+ timeWindow: '1 minute',
218
+ keyGenerator: (request) => request.ip
219
+ }
220
+ }
221
+ }, async (request, reply) => {
214
222
  return handleCredentials(request, reply, issuer);
215
223
  });
216
224
 
@@ -224,12 +232,29 @@ export async function idpPlugin(fastify, options) {
224
232
 
225
233
  // POST interaction - direct form submission (CTH compatibility)
226
234
  // This handles form submissions directly to /idp/interaction/:uid
227
- fastify.post('/idp/interaction/:uid', async (request, reply) => {
235
+ // Rate limited to prevent brute force attacks
236
+ fastify.post('/idp/interaction/:uid', {
237
+ config: {
238
+ rateLimit: {
239
+ max: 10,
240
+ timeWindow: '1 minute',
241
+ keyGenerator: (request) => request.ip
242
+ }
243
+ }
244
+ }, async (request, reply) => {
228
245
  return handleLogin(request, reply, provider);
229
246
  });
230
247
 
231
- // POST login (explicit path)
232
- fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
248
+ // POST login (explicit path) - rate limited
249
+ fastify.post('/idp/interaction/:uid/login', {
250
+ config: {
251
+ rateLimit: {
252
+ max: 10,
253
+ timeWindow: '1 minute',
254
+ keyGenerator: (request) => request.ip
255
+ }
256
+ }
257
+ }, async (request, reply) => {
233
258
  return handleLogin(request, reply, provider);
234
259
  });
235
260
 
@@ -248,7 +273,16 @@ export async function idpPlugin(fastify, options) {
248
273
  return handleRegisterGet(request, reply);
249
274
  });
250
275
 
251
- fastify.post('/idp/register', async (request, reply) => {
276
+ // Registration - rate limited to prevent spam accounts
277
+ fastify.post('/idp/register', {
278
+ config: {
279
+ rateLimit: {
280
+ max: 5,
281
+ timeWindow: '1 hour',
282
+ keyGenerator: (request) => request.ip
283
+ }
284
+ }
285
+ }, async (request, reply) => {
252
286
  return handleRegisterPost(request, reply, issuer);
253
287
  });
254
288
 
@@ -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 JSON.parse(text);
141
+ return safeJsonParse(text);
141
142
  }
142
143
 
143
144
  // Turtle/N3 - only if conneg enabled
package/src/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Fastify from 'fastify';
2
+ import rateLimit from '@fastify/rate-limit';
2
3
  import { readFile } from 'fs/promises';
3
4
  import { join, dirname } from 'path';
4
5
  import { fileURLToPath } from 'url';
@@ -127,6 +128,20 @@ export function createServer(options = {}) {
127
128
  fastify.register(idpPlugin, { issuer: idpIssuer });
128
129
  }
129
130
 
131
+ // Register rate limiting plugin
132
+ // Protects against brute force attacks and resource exhaustion
133
+ fastify.register(rateLimit, {
134
+ global: false, // Don't apply globally, only to specific routes
135
+ max: 100, // Default max requests per window
136
+ timeWindow: '1 minute',
137
+ // Custom error response
138
+ errorResponseBuilder: (request, context) => ({
139
+ error: 'Too Many Requests',
140
+ message: `Rate limit exceeded. Try again in ${Math.ceil(context.after / 1000)} seconds.`,
141
+ retryAfter: Math.ceil(context.after / 1000)
142
+ })
143
+ });
144
+
130
145
  // Global CORS preflight
131
146
  fastify.addHook('onRequest', async (request, reply) => {
132
147
  // Add CORS headers to all responses
@@ -224,8 +239,17 @@ export function createServer(options = {}) {
224
239
  }
225
240
  });
226
241
 
227
- // Pod creation endpoint
228
- fastify.post('/.pods', handleCreatePod);
242
+ // Pod creation endpoint with rate limiting
243
+ // Limit: 5 pods per IP per hour to prevent resource exhaustion and namespace squatting
244
+ fastify.post('/.pods', {
245
+ config: {
246
+ rateLimit: {
247
+ max: 5,
248
+ timeWindow: '1 hour',
249
+ keyGenerator: (request) => request.ip
250
+ }
251
+ }
252
+ }, handleCreatePod);
229
253
 
230
254
  // Mashlib static files (served from root like NSS does)
231
255
  if (mashlibEnabled) {
@@ -266,20 +290,35 @@ export function createServer(options = {}) {
266
290
  }
267
291
  }
268
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
+
269
305
  // LDP routes - using wildcard routing
306
+ // Read operations - no rate limit (handled by bodyLimit)
270
307
  fastify.get('/*', handleGet);
271
308
  fastify.head('/*', handleHead);
272
- fastify.put('/*', handlePut);
273
- fastify.delete('/*', handleDelete);
274
- fastify.post('/*', handlePost);
275
- fastify.patch('/*', handlePatch);
276
309
  fastify.options('/*', handleOptions);
277
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
+
278
317
  // Root route
279
318
  fastify.get('/', handleGet);
280
319
  fastify.head('/', handleHead);
281
320
  fastify.options('/', handleOptions);
282
- fastify.post('/', handlePost);
321
+ fastify.post('/', writeRateLimit, handlePost);
283
322
 
284
323
  return fastify;
285
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: prevent path traversal
29
- normalized = normalized.replace(/\.\./g, '');
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
- return path.join(getDataRoot(), normalized);
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: prevent path traversal
47
- normalized = normalized.replace(/\.\./g, '');
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
- // Prepend pod name to path
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
+ }