javascript-solid-server 0.0.49 → 0.0.50

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,6 +191,8 @@ 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
+ | SSRF in OIDC discovery | Critical | 🟢 Fixed | v0.0.50 |
195
+ | SSRF in client document fetch | Critical | 🟢 Fixed | v0.0.50 |
194
196
  | Unauthenticated pod creation | High | 🔴 Open | - |
195
197
  | Default token secret | High | 🔴 Open | - |
196
198
  | No rate limiting | Medium | 🔴 Open | - |
@@ -200,6 +202,11 @@ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
200
202
 
201
203
  ## Changelog
202
204
 
205
+ ### v0.0.50 (2026-01-03)
206
+ - **Fixed SSRF in OIDC discovery**: Issuer URLs are now validated before fetching (HTTPS required, private IPs blocked)
207
+ - **Fixed SSRF in client document fetch**: Client ID URLs are now validated before fetching
208
+ - Added `src/utils/ssrf.js` - URL validation utility with DNS rebinding protection
209
+
203
210
  ### v0.0.49 (2026-01-03)
204
211
  - **Fixed ACL bypass**: ACL files now require `acl:Control` permission on the protected resource
205
212
  - **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.50",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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 {
@@ -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
  });
@@ -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
+ }