javascript-solid-server 0.0.71 → 0.0.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -17,6 +17,11 @@ import { validateExternalUrl } from '../utils/ssrf.js';
17
17
  const oidcConfigCache = new Map();
18
18
  const jwksCache = new Map();
19
19
 
20
+ // Cache for DPoP jti values to prevent replay attacks
21
+ // Stores { jti: timestamp } entries, cleaned periodically
22
+ const dpopJtiCache = new Map();
23
+ const JTI_CACHE_CLEANUP_INTERVAL = 60 * 1000; // Clean every minute
24
+
20
25
  // Cache TTL (15 minutes)
21
26
  const CACHE_TTL = 15 * 60 * 1000;
22
27
 
@@ -36,6 +41,42 @@ export function addTrustedIssuer(issuer) {
36
41
  // DPoP proof max age (5 minutes)
37
42
  const DPOP_MAX_AGE = 5 * 60;
38
43
 
44
+ /**
45
+ * Clean expired jti entries from cache
46
+ * Called periodically to prevent memory growth
47
+ */
48
+ function cleanupJtiCache() {
49
+ const now = Math.floor(Date.now() / 1000);
50
+ const expiredBefore = now - DPOP_MAX_AGE;
51
+
52
+ for (const [jti, timestamp] of dpopJtiCache.entries()) {
53
+ if (timestamp < expiredBefore) {
54
+ dpopJtiCache.delete(jti);
55
+ }
56
+ }
57
+ }
58
+
59
+ // Start periodic cleanup
60
+ setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL);
61
+
62
+ /**
63
+ * Check if a jti has been used (replay attack prevention)
64
+ * @param {string} jti - The jti claim from DPoP proof
65
+ * @returns {boolean} - true if jti was already used
66
+ */
67
+ function isJtiUsed(jti) {
68
+ return dpopJtiCache.has(jti);
69
+ }
70
+
71
+ /**
72
+ * Record a jti as used
73
+ * @param {string} jti - The jti claim from DPoP proof
74
+ * @param {number} iat - The issued-at timestamp
75
+ */
76
+ function recordJti(jti, iat) {
77
+ dpopJtiCache.set(jti, iat);
78
+ }
79
+
39
80
  /**
40
81
  * Verify a Solid-OIDC request and extract WebID
41
82
  * @param {object} request - Fastify request object
@@ -175,11 +216,19 @@ async function verifyDpopProof(dpopProof, request, accessToken) {
175
216
  return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
176
217
  }
177
218
 
178
- // jti: Unique identifier (we should track these to prevent replay, but skip for now)
219
+ // jti: Unique identifier - track to prevent replay attacks
179
220
  if (!payload.jti) {
180
221
  return { thumbprint: null, error: 'DPoP proof missing jti' };
181
222
  }
182
223
 
224
+ // Check for replay attack
225
+ if (isJtiUsed(payload.jti)) {
226
+ return { thumbprint: null, error: 'DPoP proof jti already used (replay attack prevented)' };
227
+ }
228
+
229
+ // Record jti to prevent future replay
230
+ recordJti(payload.jti, payload.iat);
231
+
183
232
  // ath: Access token hash (optional but recommended)
184
233
  if (payload.ath) {
185
234
  const expectedAth = await calculateAth(accessToken);
@@ -1,6 +1,7 @@
1
1
  import { spawn, execSync } from 'child_process';
2
2
  import { existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
3
3
  import { join, resolve, dirname } from 'path';
4
+ import { getDataRoot } from '../utils/url.js';
4
5
 
5
6
  /**
6
7
  * Check if a URL path is a Git protocol request
@@ -23,20 +24,41 @@ export function isGitWriteOperation(urlPath) {
23
24
  }
24
25
 
25
26
  /**
26
- * Extract the repository path from the URL
27
+ * Extract the repository path from the URL with path traversal protection
27
28
  * @param {string} urlPath - The URL path
28
29
  * @returns {string|null} The repository relative path or null
29
30
  */
30
31
  function extractRepoPath(urlPath) {
31
32
  // Remove git service suffixes to get the repo path
32
- const cleanPath = urlPath
33
+ let cleanPath = urlPath
33
34
  .replace(/\/info\/refs.*$/, '')
34
35
  .replace(/\/git-upload-pack$/, '')
35
36
  .replace(/\/git-receive-pack$/, '');
36
37
 
37
- // Remove leading slash, use '.' for root
38
- const result = cleanPath.replace(/^\//, '');
39
- return result === '' ? '.' : result;
38
+ // Remove leading slash
39
+ cleanPath = cleanPath.replace(/^\//, '');
40
+
41
+ // Security: remove path traversal attempts (multiple passes for ....// bypass)
42
+ let previous;
43
+ do {
44
+ previous = cleanPath;
45
+ cleanPath = cleanPath.replace(/\.\./g, '');
46
+ } while (cleanPath !== previous);
47
+
48
+ // Use '.' for root/empty path
49
+ return cleanPath === '' ? '.' : cleanPath;
50
+ }
51
+
52
+ /**
53
+ * Validate that a resolved path is within the data root
54
+ * @param {string} resolvedPath - Absolute path to validate
55
+ * @param {string} dataRoot - The data root directory
56
+ * @returns {boolean} - true if path is safe
57
+ */
58
+ function isPathWithinDataRoot(resolvedPath, dataRoot) {
59
+ const normalizedRoot = resolve(dataRoot);
60
+ const normalizedPath = resolve(resolvedPath);
61
+ return normalizedPath.startsWith(normalizedRoot + '/') || normalizedPath === normalizedRoot;
40
62
  }
41
63
 
42
64
  /**
@@ -89,13 +111,18 @@ export async function handleGit(request, reply) {
89
111
  }
90
112
 
91
113
  // Handle subdomain mode
92
- let dataRoot = process.env.DATA_ROOT || './data';
114
+ let dataRoot = getDataRoot();
93
115
  if (request.podName) {
94
116
  dataRoot = join(dataRoot, request.podName);
95
117
  }
96
118
 
97
119
  const repoAbs = resolve(dataRoot, repoRelative);
98
120
 
121
+ // Security: verify resolved path is within data root (path traversal protection)
122
+ if (!isPathWithinDataRoot(repoAbs, getDataRoot())) {
123
+ return reply.code(403).send({ error: 'Path traversal detected' });
124
+ }
125
+
99
126
  // Find git directory
100
127
  const gitInfo = findGitDir(repoAbs);
101
128
  if (!gitInfo) {
@@ -376,6 +376,11 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
376
376
  return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
377
377
  }
378
378
 
379
+ // Password strength validation
380
+ if (password.length < 8) {
381
+ return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly));
382
+ }
383
+
379
384
  if (password !== confirmPassword) {
380
385
  return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
381
386
  }
package/src/utils/ssrf.js CHANGED
@@ -123,10 +123,15 @@ export async function validateExternalUrl(urlString, options = {}) {
123
123
  }
124
124
  }
125
125
  } catch (err) {
126
- // DNS resolution failed - could be a legitimate issue or attacker trying to bypass
127
- // For security, we'll allow it through but log a warning
128
- // The fetch will fail anyway if the host doesn't resolve
129
- console.warn(`DNS resolution failed for ${hostname}: ${err.message}`);
126
+ // DNS resolution failed - this could be an attacker attempting to bypass SSRF
127
+ // protection via DNS manipulation or timing attacks.
128
+ // Security: block the request rather than allowing it through
129
+ console.warn(`SSRF protection: DNS resolution failed for ${hostname}: ${err.message}`);
130
+ return {
131
+ valid: false,
132
+ error: `DNS resolution failed for hostname: ${hostname}`,
133
+ url: null
134
+ };
130
135
  }
131
136
  }
132
137
 
package/src/utils/url.js CHANGED
@@ -65,8 +65,13 @@ export function urlToPathWithPod(urlPath, podName) {
65
65
  normalized = normalized.replace(/\.\./g, '');
66
66
  } while (normalized !== previous);
67
67
 
68
- // Also sanitize podName
69
- let safePodName = podName.replace(/\.\./g, '');
68
+ // Also sanitize podName (multiple passes for ....// bypass)
69
+ let safePodName = podName;
70
+ let previousPod;
71
+ do {
72
+ previousPod = safePodName;
73
+ safePodName = safePodName.replace(/\.\./g, '');
74
+ } while (safePodName !== previousPod);
70
75
 
71
76
  // Resolve to absolute path and verify it's within DATA_ROOT
72
77
  const dataRoot = path.resolve(getDataRoot());
package/src/wac/parser.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { turtleToJsonLd } from '../rdf/turtle.js';
7
+ import { safeJsonParse } from '../utils/url.js';
7
8
 
8
9
  const ACL = 'http://www.w3.org/ns/auth/acl#';
9
10
  const FOAF = 'http://xmlns.com/foaf/0.1/';
@@ -35,9 +36,9 @@ export async function parseAcl(content, aclUrl) {
35
36
  if (typeof content === 'object' && content !== null) {
36
37
  doc = content;
37
38
  } else if (typeof content === 'string') {
38
- // Try JSON-LD first
39
+ // Try JSON-LD first (with size limit for DoS protection)
39
40
  try {
40
- doc = JSON.parse(content);
41
+ doc = safeJsonParse(content);
41
42
  } catch {
42
43
  // Not JSON, try Turtle
43
44
  try {
@@ -54,7 +55,7 @@ export async function parseAcl(content, aclUrl) {
54
55
  const authorizations = [];
55
56
 
56
57
  // Handle @graph array or single object
57
- const nodes = doc['@graph'] || [doc];
58
+ const nodes = Array.isArray(doc) ? doc : (doc['@graph'] || [doc]);
58
59
 
59
60
  for (const node of nodes) {
60
61
  if (isAuthorization(node)) {
package/test/wac.test.js CHANGED
@@ -38,6 +38,42 @@ describe('WAC Parser', () => {
38
38
  assert.ok(auths[0].modes.includes(AccessMode.WRITE));
39
39
  });
40
40
 
41
+ it('should parse top-level JSON-LD array format', async () => {
42
+ // ACL as top-level array (without @graph wrapper)
43
+ const acl = [
44
+ {
45
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
46
+ '@id': '#owner',
47
+ '@type': 'acl:Authorization',
48
+ 'acl:agent': { '@id': 'https://alice.example/#me' },
49
+ 'acl:accessTo': { '@id': 'https://alice.example/resource' },
50
+ 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }]
51
+ },
52
+ {
53
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
54
+ '@id': '#public',
55
+ '@type': 'acl:Authorization',
56
+ 'acl:agentClass': { '@id': 'foaf:Agent' },
57
+ 'acl:accessTo': { '@id': 'https://alice.example/resource' },
58
+ 'acl:mode': [{ '@id': 'acl:Read' }]
59
+ }
60
+ ];
61
+
62
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
63
+
64
+ assert.strictEqual(auths.length, 2);
65
+ // Check owner authorization
66
+ const ownerAuth = auths.find(a => a.agents.includes('https://alice.example/#me'));
67
+ assert.ok(ownerAuth, 'Should have owner authorization');
68
+ assert.ok(ownerAuth.modes.includes(AccessMode.READ));
69
+ assert.ok(ownerAuth.modes.includes(AccessMode.WRITE));
70
+ assert.ok(ownerAuth.modes.includes(AccessMode.CONTROL));
71
+ // Check public authorization
72
+ const publicAuth = auths.find(a => a.agentClasses.includes('foaf:Agent'));
73
+ assert.ok(publicAuth, 'Should have public authorization');
74
+ assert.ok(publicAuth.modes.includes(AccessMode.READ));
75
+ });
76
+
41
77
  it('should parse public access', async () => {
42
78
  const acl = {
43
79
  '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },