javascript-solid-server 0.0.70 → 0.0.72

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.70",
3
+ "version": "0.0.72",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
9
  import { AccessMode } from '../wac/parser.js';
10
10
  import * as storage from '../storage/filesystem.js';
11
11
  import { getEffectiveUrlPath } from '../utils/url.js';
12
+ import { generateDatabrowserHtml } from '../mashlib/index.js';
12
13
 
13
14
  /**
14
15
  * Check if request is authorized
@@ -113,6 +114,12 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
113
114
  // Check if browser wants HTML
114
115
  const accept = request.headers.accept || '';
115
116
  if (accept.includes('text/html')) {
117
+ // If mashlib is enabled, serve mashlib instead of static error page
118
+ // Mashlib has built-in login functionality via panes.runDataBrowser()
119
+ if (request.mashlibEnabled) {
120
+ const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
121
+ return reply.code(statusCode).type('text/html').send(generateDatabrowserHtml(request.url, cdnVersion));
122
+ }
116
123
  return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
117
124
  }
118
125
 
@@ -315,12 +322,12 @@ function getErrorPage(statusCode, isAuthenticated, request) {
315
322
  <p class="subtitle">${subtitle}</p>
316
323
 
317
324
  <div class="actions">
318
- <a href="${baseUrl}/" class="btn btn-primary">
325
+ ${is401 ? `<a href="https://solidos.solidcommunity.net/?uri=${encodeURIComponent(baseUrl + request.url)}" class="btn btn-primary">
326
+ Open in Data Browser
327
+ </a>` : ''}
328
+ <a href="${baseUrl}/" class="btn btn-secondary">
319
329
  Go to Homepage
320
330
  </a>
321
- ${is401 ? `<a href="${baseUrl}/idp/register" class="btn btn-secondary">
322
- Create Account
323
- </a>` : ''}
324
331
  </div>
325
332
 
326
333
  <div class="divider"><span>What is this?</span></div>
@@ -330,7 +337,7 @@ function getErrorPage(statusCode, isAuthenticated, request) {
330
337
  <p>
331
338
  This is a <strong>Solid Pod</strong> — a personal data store where you control your own data.
332
339
  Resources can be private, shared with specific people, or public.
333
- ${is401 ? "To access protected content, you'll need to sign in using a Solid app (like a data browser) with your WebID." : 'Ask the owner to grant you access.'}
340
+ ${is401 ? "The Data Browser lets you sign in with your WebID to access protected content." : 'Ask the owner to grant you access.'}
334
341
  </p>
335
342
  </div>
336
343
 
@@ -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 {