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.
@@ -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
- | Unauthenticated pod creation | High | 🔴 Open | - |
195
- | Default token secret | High | 🔴 Open | - |
196
- | No rate limiting | Medium | 🔴 Open | - |
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.49",
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",
@@ -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 (in production, use env var)
15
- const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
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', async (request, reply) => {
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
- fastify.post('/idp/interaction/:uid', async (request, reply) => {
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', async (request, reply) => {
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
- fastify.post('/idp/register', async (request, reply) => {
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
 
@@ -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
- fastify.post('/.pods', handleCreatePod);
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
+ }