javascript-solid-server 0.0.48 → 0.0.49

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.
@@ -0,0 +1,208 @@
1
+ # JSS Security Audit Report
2
+
3
+ **Date:** 2026-01-03
4
+ **Auditor:** Security Review
5
+ **Version Audited:** 0.0.48
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ A security audit of JavaScriptSolidServer revealed **2 critical**, **2 high**, and **2 medium** severity vulnerabilities. The most severe allows unauthenticated users to read and write ACL (Access Control List) files, effectively bypassing all authorization.
12
+
13
+ ---
14
+
15
+ ## Critical Vulnerabilities
16
+
17
+ ### 1. ACL Files Bypass Authorization (CRITICAL) ⚠️
18
+
19
+ **Location:** `src/auth/middleware.js:24-28`
20
+
21
+ ```javascript
22
+ if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
23
+ return { authorized: true, webId: null, wacAllow: '...', authError: null };
24
+ }
25
+ ```
26
+
27
+ **Description:** The authorization middleware explicitly skips authentication and authorization checks for all requests to `.acl` files. This allows any unauthenticated user to:
28
+
29
+ 1. **Read any ACL file** - Discover permission structures
30
+ 2. **Write/Create any ACL file** - Grant themselves access to any resource
31
+ 3. **Modify existing ACL files** - Lock out legitimate owners
32
+
33
+ **Proof of Concept:**
34
+ ```bash
35
+ # Read root ACL without authentication
36
+ curl https://example.com/.acl
37
+
38
+ # Create malicious ACL without authentication
39
+ curl -X PUT https://example.com/victim/.acl \
40
+ -H "Content-Type: application/ld+json" \
41
+ -d '{"@graph":[{"@id":"#attacker","@type":"acl:Authorization","acl:agent":{"@id":"https://attacker.com/card#me"},"acl:accessTo":{"@id":"https://example.com/victim/"},"acl:mode":[{"@id":"acl:Read"},{"@id":"acl:Write"},{"@id":"acl:Control"}]}]}'
42
+ ```
43
+
44
+ **Impact:** Complete authorization bypass. Attacker can gain full control of any resource.
45
+
46
+ **CVSS Score:** 9.8 (Critical)
47
+
48
+ **Fix Required:** ACL files should require `acl:Control` permission on the resource they protect.
49
+
50
+ ---
51
+
52
+ ### 2. JWT Token Signature Not Verified (CRITICAL) ⚠️
53
+
54
+ **Location:** `src/auth/token.js:93-122`
55
+
56
+ ```javascript
57
+ function verifyJwtToken(token) {
58
+ const parts = token.split('.');
59
+ if (parts.length !== 3) return null;
60
+
61
+ // Decode the payload (middle part) - NO SIGNATURE VERIFICATION!
62
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
63
+
64
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
65
+ return null;
66
+ }
67
+
68
+ if (payload.webid) {
69
+ return { webId: payload.webid, iat: payload.iat, exp: payload.exp };
70
+ }
71
+ // ...
72
+ }
73
+ ```
74
+
75
+ **Description:** The `verifyJwtToken` function decodes JWT tokens but **never verifies the cryptographic signature**. An attacker can craft arbitrary JWT tokens with any WebID.
76
+
77
+ **Proof of Concept:**
78
+ ```bash
79
+ # Forge a JWT token with attacker's WebID (signature is ignored)
80
+ # Header: {"alg":"RS256","typ":"JWT"}
81
+ # Payload: {"webid":"https://attacker.com/card#me","exp":9999999999}
82
+ curl https://example.com/private/ \
83
+ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJ3ZWJpZCI6Imh0dHBzOi8vYXR0YWNrZXIuY29tL2NhcmQjbWUiLCJleHAiOjk5OTk5OTk5OTl9.fakesig"
84
+ ```
85
+
86
+ **Impact:** Complete authentication bypass. Attacker can impersonate any user.
87
+
88
+ **CVSS Score:** 9.8 (Critical)
89
+
90
+ **Fix Required:** Verify JWT signatures against the issuer's JWKS before accepting tokens.
91
+
92
+ ---
93
+
94
+ ## High Severity Vulnerabilities
95
+
96
+ ### 3. Pod Creation Without Authentication (HIGH)
97
+
98
+ **Location:** `src/server.js:203,228`
99
+
100
+ ```javascript
101
+ // Auth bypass list includes /.pods
102
+ if (request.url === '/.pods' || ...) {
103
+ return; // Skip auth
104
+ }
105
+
106
+ // Anyone can create pods
107
+ fastify.post('/.pods', handleCreatePod);
108
+ ```
109
+
110
+ **Description:** The `/.pods` endpoint allows anyone to create new pods without authentication.
111
+
112
+ **Impact:**
113
+ - Resource exhaustion (DoS)
114
+ - Username/namespace squatting
115
+ - Disk space exhaustion
116
+
117
+ **CVSS Score:** 7.5 (High)
118
+
119
+ **Fix Required:** Require authentication or implement rate limiting and CAPTCHA.
120
+
121
+ ---
122
+
123
+ ### 4. Default Token Secret in Production (HIGH)
124
+
125
+ **Location:** `src/auth/token.js:15`
126
+
127
+ ```javascript
128
+ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
129
+ ```
130
+
131
+ **Description:** If `TOKEN_SECRET` environment variable is not set, a hardcoded default secret is used.
132
+
133
+ **Impact:** Tokens can be forged by anyone who knows the default secret.
134
+
135
+ **CVSS Score:** 8.1 (High)
136
+
137
+ **Fix Required:** Fail to start if TOKEN_SECRET is not set in production, or generate a random secret on first run.
138
+
139
+ ---
140
+
141
+ ## Medium Severity Vulnerabilities
142
+
143
+ ### 5. No Rate Limiting on Authentication Endpoints (MEDIUM)
144
+
145
+ **Location:** `src/idp/interactions.js`, `src/idp/credentials.js`
146
+
147
+ **Description:** Login, registration, and credential endpoints have no rate limiting, allowing brute force attacks.
148
+
149
+ **Impact:** Account takeover through credential stuffing or brute force.
150
+
151
+ **CVSS Score:** 5.3 (Medium)
152
+
153
+ **Fix Required:** Implement rate limiting (e.g., 5 attempts per minute per IP).
154
+
155
+ ---
156
+
157
+ ### 6. Information Disclosure via Error Messages (MEDIUM)
158
+
159
+ **Location:** Various handlers
160
+
161
+ **Description:** Error messages may reveal internal paths or stack traces.
162
+
163
+ **Impact:** Information leakage useful for further attacks.
164
+
165
+ **CVSS Score:** 4.3 (Medium)
166
+
167
+ ---
168
+
169
+ ## Recommendations
170
+
171
+ ### Immediate Actions (Critical)
172
+
173
+ 1. **Fix ACL bypass** - Require `acl:Control` permission to modify ACL files
174
+ 2. **Verify JWT signatures** - Use `jose` library to verify against issuer JWKS
175
+
176
+ ### Short-term Actions (High)
177
+
178
+ 3. **Protect pod creation** - Add authentication or rate limiting
179
+ 4. **Enforce TOKEN_SECRET** - Fail startup if not configured
180
+
181
+ ### Medium-term Actions
182
+
183
+ 5. **Add rate limiting** - Use `@fastify/rate-limit` plugin
184
+ 6. **Sanitize error messages** - Remove internal details from user-facing errors
185
+
186
+ ---
187
+
188
+ ## Remediation Status
189
+
190
+ | Issue | Severity | Status | Fixed In |
191
+ |-------|----------|--------|----------|
192
+ | ACL bypass | Critical | 🟢 Fixed | v0.0.49 |
193
+ | JWT signature bypass | Critical | 🟢 Fixed | v0.0.49 |
194
+ | Unauthenticated pod creation | High | 🔴 Open | - |
195
+ | Default token secret | High | 🔴 Open | - |
196
+ | No rate limiting | Medium | 🔴 Open | - |
197
+ | Information disclosure | Medium | 🔴 Open | - |
198
+
199
+ ---
200
+
201
+ ## Changelog
202
+
203
+ ### v0.0.49 (2026-01-03)
204
+ - **Fixed ACL bypass**: ACL files now require `acl:Control` permission on the protected resource
205
+ - **Fixed JWT signature bypass**: JWTs are now verified against the IdP's JWKS before accepting
206
+
207
+ *Report generated: 2026-01-03*
208
+ *Last updated: 2026-01-03*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { getWebIdFromRequestAsync } from './token.js';
8
8
  import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
+ import { AccessMode } from '../wac/parser.js';
9
10
  import * as storage from '../storage/filesystem.js';
10
11
  import { getEffectiveUrlPath } from '../utils/url.js';
11
12
 
@@ -21,15 +22,19 @@ export async function authorize(request, reply, options = {}) {
21
22
  const urlPath = request.url.split('?')[0];
22
23
  const method = request.method;
23
24
 
24
- // Skip auth for .acl files (they need special handling)
25
- // and for OPTIONS (CORS preflight)
26
- if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
25
+ // OPTIONS is always allowed (CORS preflight)
26
+ if (method === 'OPTIONS') {
27
27
  return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
28
28
  }
29
29
 
30
30
  // Get WebID from token (supports both simple and Solid-OIDC tokens)
31
31
  const { webId, error: authError } = await getWebIdFromRequestAsync(request);
32
32
 
33
+ // ACL files require special handling - check Control permission on protected resource
34
+ if (urlPath.endsWith('.acl')) {
35
+ return authorizeAclAccess(request, urlPath, method, webId, authError);
36
+ }
37
+
33
38
  // Log auth failures for debugging
34
39
  if (authError) {
35
40
  request.log.warn({ authError, method, urlPath, hasAuth: !!request.headers.authorization }, 'Auth error');
@@ -114,3 +119,39 @@ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError =
114
119
  });
115
120
  }
116
121
  }
122
+
123
+ /**
124
+ * Authorize access to ACL files
125
+ * ACL files require acl:Control permission on the resource they protect
126
+ *
127
+ * @param {object} request - Fastify request
128
+ * @param {string} urlPath - URL path to the ACL file
129
+ * @param {string} method - HTTP method
130
+ * @param {string|null} webId - Authenticated user's WebID
131
+ * @param {string|null} authError - Authentication error if any
132
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
133
+ */
134
+ async function authorizeAclAccess(request, urlPath, method, webId, authError) {
135
+ // Determine the protected resource URL
136
+ // /foo/.acl protects /foo/ (container)
137
+ // /foo/bar.acl protects /foo/bar (resource)
138
+ const protectedPath = urlPath.replace(/\.acl$/, '');
139
+ const isProtectedContainer = protectedPath.endsWith('/');
140
+ const protectedUrl = `${request.protocol}://${request.hostname}${protectedPath}`;
141
+
142
+ // Get storage path for the protected resource
143
+ const storagePath = getEffectiveUrlPath(request).replace(/\.acl$/, '');
144
+
145
+ // All ACL operations require Control permission on the protected resource
146
+ // This is stricter than the Solid spec (which allows Read for reading ACLs)
147
+ // but simpler and more secure
148
+ const { allowed, wacAllow } = await checkAccess({
149
+ resourceUrl: protectedUrl,
150
+ resourcePath: storagePath,
151
+ isContainer: isProtectedContainer,
152
+ agentWebId: webId,
153
+ requiredMode: AccessMode.CONTROL
154
+ });
155
+
156
+ return { authorized: allowed, webId, wacAllow, authError };
157
+ }
package/src/auth/token.js CHANGED
@@ -37,7 +37,11 @@ export function createToken(webId, expiresIn = 3600) {
37
37
  }
38
38
 
39
39
  /**
40
- * Verify and decode a token (simple 2-part or JWT 3-part)
40
+ * Verify and decode a simple token (2-part HMAC-signed)
41
+ *
42
+ * SECURITY: Only accepts 2-part simple tokens signed with HMAC.
43
+ * JWT tokens (3-part) require async verification via verifyTokenAsync().
44
+ *
41
45
  * @param {string} token - The token to verify
42
46
  * @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
43
47
  */
@@ -48,9 +52,9 @@ export function verifyToken(token) {
48
52
 
49
53
  const parts = token.split('.');
50
54
 
51
- // Handle JWT tokens (3 parts) from credentials endpoint
55
+ // JWT tokens (3 parts) require async verification - reject in sync function
52
56
  if (parts.length === 3) {
53
- return verifyJwtToken(token);
57
+ return null;
54
58
  }
55
59
 
56
60
  if (parts.length !== 2) {
@@ -59,13 +63,19 @@ export function verifyToken(token) {
59
63
 
60
64
  const [data, signature] = parts;
61
65
 
62
- // Verify signature
66
+ // Verify HMAC signature
63
67
  const expectedSig = crypto
64
68
  .createHmac('sha256', SECRET)
65
69
  .update(data)
66
70
  .digest('base64url');
67
71
 
68
- if (signature !== expectedSig) {
72
+ // Constant-time comparison to prevent timing attacks
73
+ try {
74
+ if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
75
+ return null;
76
+ }
77
+ } catch {
78
+ // If lengths don't match, timingSafeEqual throws
69
79
  return null;
70
80
  }
71
81
 
@@ -85,38 +95,45 @@ export function verifyToken(token) {
85
95
  }
86
96
 
87
97
  /**
88
- * Verify a JWT token from credentials endpoint
89
- * JWT tokens are self-contained and signed with the IdP's private key
98
+ * Verify a JWT token from the credentials endpoint
99
+ * Properly verifies signature against IdP's JWKS
100
+ *
90
101
  * @param {string} token - JWT token
91
- * @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
102
+ * @returns {Promise<{webId: string, iat: number, exp: number} | null>}
92
103
  */
93
- function verifyJwtToken(token) {
104
+ async function verifyJwtFromIdp(token) {
94
105
  try {
95
- const parts = token.split('.');
96
- if (parts.length !== 3) {
97
- return null;
98
- }
99
-
100
- // Decode the payload (middle part)
101
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
106
+ // Dynamically import to avoid circular dependencies
107
+ const { getPublicJwks } = await import('../idp/keys.js');
108
+ const jose = await import('jose');
102
109
 
103
- // Check expiration
104
- if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
110
+ const jwks = await getPublicJwks();
111
+ if (!jwks || !jwks.keys || jwks.keys.length === 0) {
105
112
  return null;
106
113
  }
107
114
 
108
- // JWT from credentials endpoint uses 'webid' claim (lowercase)
109
- if (payload.webid) {
110
- return { webId: payload.webid, iat: payload.iat, exp: payload.exp };
111
- }
115
+ // Create JWKS for verification
116
+ const keySet = jose.createLocalJWKSet(jwks);
112
117
 
113
- // Also check uppercase WebId for compatibility
114
- if (payload.webId) {
115
- return payload;
118
+ // Verify the token
119
+ const { payload } = await jose.jwtVerify(token, keySet, {
120
+ // Allow some clock skew
121
+ clockTolerance: 60,
122
+ });
123
+
124
+ // Extract webid claim
125
+ const webId = payload.webid || payload.webId || payload.sub;
126
+ if (!webId) {
127
+ return null;
116
128
  }
117
129
 
118
- return null;
119
- } catch {
130
+ return {
131
+ webId,
132
+ iat: payload.iat,
133
+ exp: payload.exp
134
+ };
135
+ } catch (err) {
136
+ // Verification failed - invalid signature, expired, etc.
120
137
  return null;
121
138
  }
122
139
  }
@@ -190,16 +207,27 @@ export async function getWebIdFromRequestAsync(request) {
190
207
  return verifyNostrAuth(request);
191
208
  }
192
209
 
193
- // Fall back to simple Bearer tokens
210
+ // Fall back to Bearer tokens
194
211
  const token = extractToken(authHeader);
195
212
  if (!token) {
196
213
  return { webId: null, error: null };
197
214
  }
198
215
 
216
+ // Try simple 2-part token first
199
217
  const payload = verifyToken(token);
200
218
  if (payload?.webId) {
201
219
  return { webId: payload.webId, error: null };
202
220
  }
203
221
 
222
+ // If 3-part JWT, verify against IdP's JWKS
223
+ const parts = token.split('.');
224
+ if (parts.length === 3) {
225
+ const jwtPayload = await verifyJwtFromIdp(token);
226
+ if (jwtPayload?.webId) {
227
+ return { webId: jwtPayload.webId, error: null };
228
+ }
229
+ return { webId: null, error: 'Invalid or unverifiable JWT token' };
230
+ }
231
+
204
232
  return { webId: null, error: 'Invalid token' };
205
233
  }
package/test/wac.test.js CHANGED
@@ -226,15 +226,22 @@ describe('WAC Integration', () => {
226
226
 
227
227
  describe('ACL Files', () => {
228
228
  it('should create root .acl on pod creation', async () => {
229
- const res = await request('/wactest/.acl');
229
+ // ACL files require Control permission - must be authenticated as pod owner
230
+ const res = await request('/wactest/.acl', { auth: 'wactest' });
230
231
 
231
232
  assertStatus(res, 200);
232
233
  const content = await res.json();
233
234
  assert.ok(content['@graph'], 'Should be JSON-LD');
234
235
  });
235
236
 
237
+ it('should deny unauthenticated access to .acl files', async () => {
238
+ // Security: ACL files must require authentication
239
+ const res = await request('/wactest/.acl');
240
+ assertStatus(res, 401);
241
+ });
242
+
236
243
  it('should create private folder .acl', async () => {
237
- const res = await request('/wactest/private/.acl');
244
+ const res = await request('/wactest/private/.acl', { auth: 'wactest' });
238
245
 
239
246
  assertStatus(res, 200);
240
247
  const content = await res.json();
@@ -248,7 +255,7 @@ describe('WAC Integration', () => {
248
255
  });
249
256
 
250
257
  it('should create inbox .acl with public append', async () => {
251
- const res = await request('/wactest/inbox/.acl');
258
+ const res = await request('/wactest/inbox/.acl', { auth: 'wactest' });
252
259
 
253
260
  assertStatus(res, 200);
254
261
  const content = await res.json();