javascript-solid-server 0.0.49 → 0.0.51
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/SECURITY-AUDIT-2026-01-03.md +15 -3
- package/package.json +2 -1
- package/src/auth/solid-oidc.js +13 -0
- package/src/auth/token.js +23 -2
- package/src/idp/index.js +40 -6
- package/src/idp/provider.js +14 -0
- package/src/server.js +26 -2
- package/src/utils/ssrf.js +152 -0
|
@@ -191,15 +191,27 @@ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
|
191
191
|
|-------|----------|--------|----------|
|
|
192
192
|
| ACL bypass | Critical | 🟢 Fixed | v0.0.49 |
|
|
193
193
|
| JWT signature bypass | Critical | 🟢 Fixed | v0.0.49 |
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
|
194
|
+
| SSRF in OIDC discovery | Critical | 🟢 Fixed | v0.0.50 |
|
|
195
|
+
| SSRF in client document fetch | Critical | 🟢 Fixed | v0.0.50 |
|
|
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 |
|
|
197
199
|
| Information disclosure | Medium | 🔴 Open | - |
|
|
198
200
|
|
|
199
201
|
---
|
|
200
202
|
|
|
201
203
|
## Changelog
|
|
202
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
|
+
|
|
210
|
+
### v0.0.50 (2026-01-03)
|
|
211
|
+
- **Fixed SSRF in OIDC discovery**: Issuer URLs are now validated before fetching (HTTPS required, private IPs blocked)
|
|
212
|
+
- **Fixed SSRF in client document fetch**: Client ID URLs are now validated before fetching
|
|
213
|
+
- Added `src/utils/ssrf.js` - URL validation utility with DNS rebinding protection
|
|
214
|
+
|
|
203
215
|
### v0.0.49 (2026-01-03)
|
|
204
216
|
- **Fixed ACL bypass**: ACL files now require `acl:Control` permission on the protected resource
|
|
205
217
|
- **Fixed JWT signature bypass**: JWTs are now verified against the IdP's JWKS before accepting
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.51",
|
|
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/solid-oidc.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as jose from 'jose';
|
|
14
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
14
15
|
|
|
15
16
|
// Cache for OIDC configurations and JWKS
|
|
16
17
|
const oidcConfigCache = new Map();
|
|
@@ -197,6 +198,7 @@ async function calculateAth(accessToken) {
|
|
|
197
198
|
|
|
198
199
|
/**
|
|
199
200
|
* Fetch and cache OIDC configuration
|
|
201
|
+
* SECURITY: Validates issuer URL to prevent SSRF attacks
|
|
200
202
|
*/
|
|
201
203
|
async function getOidcConfig(issuer) {
|
|
202
204
|
const cached = oidcConfigCache.get(issuer);
|
|
@@ -204,6 +206,17 @@ async function getOidcConfig(issuer) {
|
|
|
204
206
|
return cached.config;
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
// SSRF Protection: Validate issuer URL before fetching
|
|
210
|
+
const validation = await validateExternalUrl(issuer, {
|
|
211
|
+
requireHttps: true,
|
|
212
|
+
blockPrivateIPs: true,
|
|
213
|
+
resolveDNS: true
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (!validation.valid) {
|
|
217
|
+
throw new Error(`Invalid OIDC issuer: ${validation.error}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
207
220
|
const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
208
221
|
|
|
209
222
|
try {
|
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
|
|
15
|
-
|
|
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
|
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',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
package/src/idp/provider.js
CHANGED
|
@@ -7,6 +7,7 @@ import Provider from 'oidc-provider';
|
|
|
7
7
|
import { createAdapter } from './adapter.js';
|
|
8
8
|
import { getJwks, getCookieKeys } from './keys.js';
|
|
9
9
|
import { getAccountForProvider } from './accounts.js';
|
|
10
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
10
11
|
|
|
11
12
|
// Cache for fetched client documents
|
|
12
13
|
const clientDocumentCache = new Map();
|
|
@@ -14,6 +15,7 @@ const CLIENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Fetch and validate a Solid-OIDC Client Identifier Document
|
|
18
|
+
* SECURITY: Validates client_id URL to prevent SSRF attacks
|
|
17
19
|
* @param {string} clientId - URL to the client document
|
|
18
20
|
* @returns {Promise<object|null>} - Client metadata or null
|
|
19
21
|
*/
|
|
@@ -25,6 +27,18 @@ async function fetchClientDocument(clientId) {
|
|
|
25
27
|
return cached.data;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
// SSRF Protection: Validate client_id URL before fetching
|
|
31
|
+
const validation = await validateExternalUrl(clientId, {
|
|
32
|
+
requireHttps: true,
|
|
33
|
+
blockPrivateIPs: true,
|
|
34
|
+
resolveDNS: true
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!validation.valid) {
|
|
38
|
+
console.error(`SSRF protection blocked client_id ${clientId}: ${validation.error}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
const response = await fetch(clientId, {
|
|
29
43
|
headers: { 'Accept': 'application/json, application/ld+json' },
|
|
30
44
|
});
|
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
|
-
|
|
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) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF Protection Utilities
|
|
3
|
+
* Validates URLs before making external requests to prevent Server-Side Request Forgery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isIP } from 'net';
|
|
7
|
+
import dns from 'dns/promises';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if an IP address is private/internal
|
|
11
|
+
* Blocks: localhost, private ranges, link-local, loopback, etc.
|
|
12
|
+
* @param {string} ip - IP address to check
|
|
13
|
+
* @returns {boolean} - true if private/internal
|
|
14
|
+
*/
|
|
15
|
+
export function isPrivateIP(ip) {
|
|
16
|
+
// IPv4 private/reserved ranges
|
|
17
|
+
const privateRanges = [
|
|
18
|
+
/^127\./, // Loopback (127.0.0.0/8)
|
|
19
|
+
/^10\./, // Private Class A (10.0.0.0/8)
|
|
20
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B (172.16.0.0/12)
|
|
21
|
+
/^192\.168\./, // Private Class C (192.168.0.0/16)
|
|
22
|
+
/^169\.254\./, // Link-local (169.254.0.0/16) - AWS/cloud metadata!
|
|
23
|
+
/^0\./, // Current network (0.0.0.0/8)
|
|
24
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // Shared address space (100.64.0.0/10)
|
|
25
|
+
/^192\.0\.0\./, // IETF Protocol Assignments (192.0.0.0/24)
|
|
26
|
+
/^192\.0\.2\./, // TEST-NET-1 (192.0.2.0/24)
|
|
27
|
+
/^198\.51\.100\./, // TEST-NET-2 (198.51.100.0/24)
|
|
28
|
+
/^203\.0\.113\./, // TEST-NET-3 (203.0.113.0/24)
|
|
29
|
+
/^224\./, // Multicast (224.0.0.0/4)
|
|
30
|
+
/^240\./, // Reserved (240.0.0.0/4)
|
|
31
|
+
/^255\.255\.255\.255$/, // Broadcast
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// IPv6 private/reserved
|
|
35
|
+
const ipv6Private = [
|
|
36
|
+
/^::1$/, // Loopback
|
|
37
|
+
/^fe80:/i, // Link-local
|
|
38
|
+
/^fc00:/i, // Unique local (fc00::/7)
|
|
39
|
+
/^fd00:/i, // Unique local
|
|
40
|
+
/^ff00:/i, // Multicast
|
|
41
|
+
/^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Check IPv4
|
|
45
|
+
for (const range of privateRanges) {
|
|
46
|
+
if (range.test(ip)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check IPv6
|
|
52
|
+
for (const range of ipv6Private) {
|
|
53
|
+
if (range.test(ip)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a URL for safe external fetching
|
|
63
|
+
* @param {string} urlString - URL to validate
|
|
64
|
+
* @param {object} options - Validation options
|
|
65
|
+
* @param {boolean} options.requireHttps - Require HTTPS (default true)
|
|
66
|
+
* @param {boolean} options.blockPrivateIPs - Block private IPs (default true)
|
|
67
|
+
* @param {boolean} options.resolveDNS - Resolve hostname to check IP (default true)
|
|
68
|
+
* @returns {Promise<{valid: boolean, error: string|null, url: URL|null}>}
|
|
69
|
+
*/
|
|
70
|
+
export async function validateExternalUrl(urlString, options = {}) {
|
|
71
|
+
const {
|
|
72
|
+
requireHttps = true,
|
|
73
|
+
blockPrivateIPs = true,
|
|
74
|
+
resolveDNS = true,
|
|
75
|
+
} = options;
|
|
76
|
+
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(urlString);
|
|
80
|
+
} catch {
|
|
81
|
+
return { valid: false, error: 'Invalid URL format', url: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check protocol
|
|
85
|
+
if (requireHttps && url.protocol !== 'https:') {
|
|
86
|
+
return { valid: false, error: 'URL must use HTTPS', url: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
90
|
+
return { valid: false, error: 'URL must use HTTP or HTTPS', url: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hostname = url.hostname;
|
|
94
|
+
|
|
95
|
+
// Block localhost variants
|
|
96
|
+
const localhostPatterns = ['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'];
|
|
97
|
+
if (localhostPatterns.includes(hostname.toLowerCase())) {
|
|
98
|
+
return { valid: false, error: 'localhost URLs are not allowed', url: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// If hostname is an IP, check directly
|
|
102
|
+
if (isIP(hostname)) {
|
|
103
|
+
if (blockPrivateIPs && isPrivateIP(hostname)) {
|
|
104
|
+
return { valid: false, error: 'Private/internal IP addresses are not allowed', url: null };
|
|
105
|
+
}
|
|
106
|
+
return { valid: true, error: null, url };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Resolve DNS to check for private IPs (DNS rebinding protection)
|
|
110
|
+
if (resolveDNS && blockPrivateIPs) {
|
|
111
|
+
try {
|
|
112
|
+
const addresses = await dns.resolve4(hostname).catch(() => []);
|
|
113
|
+
const addresses6 = await dns.resolve6(hostname).catch(() => []);
|
|
114
|
+
const allAddresses = [...addresses, ...addresses6];
|
|
115
|
+
|
|
116
|
+
for (const ip of allAddresses) {
|
|
117
|
+
if (isPrivateIP(ip)) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
error: `Hostname ${hostname} resolves to private IP ${ip}`,
|
|
121
|
+
url: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
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}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { valid: true, error: null, url };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wrapper for fetch that validates URL first
|
|
138
|
+
* @param {string} urlString - URL to fetch
|
|
139
|
+
* @param {object} fetchOptions - Options for fetch()
|
|
140
|
+
* @param {object} validationOptions - Options for URL validation
|
|
141
|
+
* @returns {Promise<Response>}
|
|
142
|
+
* @throws {Error} If URL validation fails
|
|
143
|
+
*/
|
|
144
|
+
export async function safeFetch(urlString, fetchOptions = {}, validationOptions = {}) {
|
|
145
|
+
const validation = await validateExternalUrl(urlString, validationOptions);
|
|
146
|
+
|
|
147
|
+
if (!validation.valid) {
|
|
148
|
+
throw new Error(`SSRF protection: ${validation.error}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return fetch(urlString, fetchOptions);
|
|
152
|
+
}
|