javascript-solid-server 0.0.71 → 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/.claude/settings.local.json +6 -1
- package/SECURITY-AUDIT-2026-01-05.md +382 -0
- package/docs/design/nostr-solid-browser-extension.md +1465 -0
- package/package.json +1 -1
- package/src/auth/solid-oidc.js +50 -1
- package/src/handlers/git.js +33 -6
- package/src/idp/interactions.js +5 -0
- package/src/utils/ssrf.js +9 -4
- package/src/utils/url.js +7 -2
- package/src/wac/parser.js +3 -2
- package/SECURITY-AUDIT-2026-01-15.md +0 -514
package/package.json
CHANGED
package/src/auth/solid-oidc.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/handlers/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
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) {
|
package/src/idp/interactions.js
CHANGED
|
@@ -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
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
|
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 =
|
|
41
|
+
doc = safeJsonParse(content);
|
|
41
42
|
} catch {
|
|
42
43
|
// Not JSON, try Turtle
|
|
43
44
|
try {
|