diff --git a/calm-hub-ui/src/service/adr-service/adr-service.tsx b/calm-hub-ui/src/service/adr-service/adr-service.tsx index 28141e720..6333a58d1 100644 --- a/calm-hub-ui/src/service/adr-service/adr-service.tsx +++ b/calm-hub-ui/src/service/adr-service/adr-service.tsx @@ -23,9 +23,8 @@ export class AdrService { }) .then((res) => res.data.values) .catch((error) => { - const errorMessage = `Error fetching adr IDs for namespace ${namespace}:`; - console.error(errorMessage, error); - return Promise.reject(new Error(errorMessage)); + console.error('Error fetching adr IDs for namespace:', namespace, error); + return Promise.reject(new Error('Error fetching adr IDs for namespace: ' + namespace)); }); } @@ -40,9 +39,8 @@ export class AdrService { }) .then((res) => res.data.values) .catch((error) => { - const errorMessage = `Error fetching revisions for ADR ID ${adrID}:`; - console.error(errorMessage, error); - return Promise.reject(new Error(errorMessage)); + console.error('Error fetching revisions for ADR ID:', adrID, error); + return Promise.reject(new Error('Error fetching revisions for ADR ID: ' + adrID)); }); } @@ -57,9 +55,8 @@ export class AdrService { }) .then((res) => res.data) .catch((error) => { - const errorMessage = `Error fetching adr for namespace ${namespace}, adr ID ${adrID}, revision ${revision}:`; - console.error(errorMessage, error); - return Promise.reject(new Error(errorMessage)); + console.error('Error fetching adr for namespace:', namespace, 'adr ID:', adrID, 'revision:', revision, error); + return Promise.reject(new Error('Error fetching adr for namespace: ' + namespace + ', adr ID: ' + adrID + ', revision: ' + revision)); }); } } diff --git a/calm-hub-ui/src/service/calm-service.tsx b/calm-hub-ui/src/service/calm-service.tsx index a397523c1..a4d35d2fc 100644 --- a/calm-hub-ui/src/service/calm-service.tsx +++ b/calm-hub-ui/src/service/calm-service.tsx @@ -37,7 +37,7 @@ export async function fetchPatternIDs( const data = await res.json(); setPatternIDs(data.values.map((num: number) => num.toString())); } catch (error) { - console.error(`Error fetching pattern IDs for namespace ${namespace}:`, error); + console.error('Error fetching pattern IDs for namespace:', namespace, error); } } @@ -54,7 +54,7 @@ export async function fetchFlowIDs(namespace: string, setFlowIDs: (flowIDs: stri const data = await res.json(); setFlowIDs(data.values.map((id: number) => id.toString())); } catch (error) { - console.error(`Error fetching flow IDs for namespace ${namespace}:`, error); + console.error('Error fetching flow IDs for namespace:', namespace, error); } } @@ -75,7 +75,7 @@ export async function fetchPatternVersions( const data = await res.json(); setVersions(data.values); } catch (error) { - console.error(`Error fetching versions for pattern ID ${patternID}:`, error); + console.error('Error fetching versions for pattern ID:', patternID, error); } } @@ -96,7 +96,7 @@ export async function fetchFlowVersions( const data = await res.json(); setFlowVersions(data.values); } catch (error) { - console.error(`Error fetching versions for flow ID ${flowID}:`, error); + console.error('Error fetching versions for flow ID:', flowID, error); } } @@ -128,10 +128,7 @@ export async function fetchPattern( }; setPattern(data); } catch (error) { - console.error( - `Error fetching pattern for namespace ${namespace}, pattern ID ${patternID}, version ${version}:`, - error - ); + console.error('Error fetching pattern for namespace:', namespace, 'pattern ID:', patternID, 'version:', version, error); } } @@ -163,10 +160,7 @@ export async function fetchFlow( }; setFlow(data); } catch (error) { - console.error( - `Error fetching flow for namespace ${namespace}, flow ID ${flowID}, version ${version}:`, - error - ); + console.error('Error fetching flow for namespace:', namespace, 'flow ID:', flowID, 'version:', version, error); } } @@ -186,7 +180,7 @@ export async function fetchArchitectureIDs( const data = await res.json(); setArchitectureIDs(data.values.map((id: number) => id.toString())); } catch (error) { - console.error(`Error fetching architecture IDs for namespace ${namespace}:`, error); + console.error('Error fetching architecture IDs for namespace:', namespace, error); } } @@ -210,7 +204,7 @@ export async function fetchArchitectureVersions( const data = await res.json(); setVersions(data.values); } catch (error) { - console.error(`Error fetching versions for architecture ID ${architectureID}:`, error); + console.error('Error fetching versions for architecture ID:', architectureID, error); } } @@ -242,9 +236,6 @@ export async function fetchArchitecture( }; setArchitecture(data); } catch (error) { - console.error( - `Error fetching architecture for namespace ${namespace}, architecture ID ${architectureID}, version ${version}:`, - error - ); + console.error('Error fetching architecture for namespace:', namespace, 'architecture ID:', architectureID, 'version:', version, error); } } diff --git a/package-lock.json b/package-lock.json index 328a56ed2..51ba5a27b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10949,13 +10949,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -10967,13 +10965,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -10985,13 +10981,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -11003,13 +10997,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -11021,13 +11013,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -11039,13 +11029,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -11057,13 +11045,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -11075,13 +11061,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -11093,13 +11077,11 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -11111,13 +11093,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } diff --git a/shared/src/document-loader/calmhub-document-loader.spec.ts b/shared/src/document-loader/calmhub-document-loader.spec.ts index 60b10573d..4ab258f3b 100644 --- a/shared/src/document-loader/calmhub-document-loader.spec.ts +++ b/shared/src/document-loader/calmhub-document-loader.spec.ts @@ -47,4 +47,28 @@ describe('calmhub-document-loader', () => { await expect(calmHubDocumentLoader.loadMissingDocument(traversalUrl, 'schema')) .rejects.toThrow('directory traversal'); }); + + it('rejects percent-encoded directory traversal (%2e%2e)', async () => { + const traversalUrl = 'calm:/schemas/%2e%2e/admin/secrets.json'; + await expect(calmHubDocumentLoader.loadMissingDocument(traversalUrl, 'schema')) + .rejects.toThrow('directory traversal'); + }); + + it('rejects percent-encoded path separator (%2f)', async () => { + const traversalUrl = 'calm:/schemas%2f..%2fadmin/secrets.json'; + await expect(calmHubDocumentLoader.loadMissingDocument(traversalUrl, 'schema')) + .rejects.toThrow('directory traversal'); + }); + + it('rejects double-encoded traversal (%252e%252e)', async () => { + const traversalUrl = 'calm:/schemas/%252e%252e/admin/secrets.json'; + await expect(calmHubDocumentLoader.loadMissingDocument(traversalUrl, 'schema')) + .rejects.toThrow('directory traversal'); + }); + + it('rejects malformed percent-encoding in path', async () => { + const malformedUrl = 'calm:/schemas/%ZZ/invalid.json'; + await expect(calmHubDocumentLoader.loadMissingDocument(malformedUrl, 'schema')) + .rejects.toThrow('invalid percent-encoding'); + }); }); \ No newline at end of file diff --git a/shared/src/document-loader/calmhub-document-loader.ts b/shared/src/document-loader/calmhub-document-loader.ts index c310166ba..7796d378b 100644 --- a/shared/src/document-loader/calmhub-document-loader.ts +++ b/shared/src/document-loader/calmhub-document-loader.ts @@ -52,16 +52,34 @@ export class CalmHubDocumentLoader implements DocumentLoader { throw new Error(`CalmHubDocumentLoader only loads documents with protocol '${CALM_HUB_PROTO}'. (Requested: ${protocol})`); } // The URL constructor normalizes '..' segments, so url.pathname is already resolved. - // Reject if the original input contained traversal sequences before normalization. - if (documentId.includes('/..')) { + // Reject if the original input contained traversal sequences before normalization, + // including percent-encoded path separators (%2f, %5c), dot sequences (%2e), and + // double-encoded percent signs (%25) that could hide traversal after further decoding. + if (documentId.includes('/..') || /(%2e(%2e|\.)|\.%2e|%2f|%5c|%25)/i.test(documentId)) { throw new Error(`CalmHubDocumentLoader rejected path containing directory traversal in: ${documentId}`); } - const path = url.pathname; + // Reconstruct a safe path by decoding each URL path segment individually, validating + // for traversal sequences and path separators, then re-encoding. This prevents SSRF + // via path injection while avoiding double-encoding of legitimate percent-escapes. + const segments = url.pathname.split('/').filter(s => s.length > 0); + const decodedSegments = segments.map(s => { + let decoded: string; + try { + decoded = decodeURIComponent(s); + } catch { + throw new Error(`CalmHubDocumentLoader rejected invalid percent-encoding in path: ${documentId}`); + } + if (decoded === '..' || decoded === '.' || decoded.includes('/') || decoded.includes('\\')) { + throw new Error(`CalmHubDocumentLoader rejected path containing directory traversal in: ${documentId}`); + } + return decoded; + }); + const safePath = '/' + decodedSegments.map(s => encodeURIComponent(s)).join('/'); - this.logger.debug(`Loading CALM schema from ${this.calmHubUrl}${path}`); + this.logger.debug(`Loading CALM schema from ${this.calmHubUrl}${safePath}`); // TODO gracefully handle 404s and other errors - const response = await this.ax.get(path); + const response = await this.ax.get(safePath); const document = response.data; this.logger.debug('Successfully loaded document from CALMHub with id ' + documentId); return document; diff --git a/shared/src/document-loader/direct-url-document-loader.spec.ts b/shared/src/document-loader/direct-url-document-loader.spec.ts index 71ac8480e..241d81c17 100644 --- a/shared/src/document-loader/direct-url-document-loader.spec.ts +++ b/shared/src/document-loader/direct-url-document-loader.spec.ts @@ -45,4 +45,33 @@ describe('direct-url-document-loader', () => { await expect(directUrlDocumentLoader.loadMissingDocument(ftpUrl, 'schema')) .rejects.toThrow('Only HTTP and HTTPS are allowed'); }); + + it.each([ + 'http://localhost/secret', + 'http://localhost./secret', + 'http://127.0.0.1/secret', + 'http://10.0.0.1/secret', + 'http://172.16.0.1/secret', + 'http://192.168.1.1/secret', + 'http://169.254.169.254/latest/meta-data/', + 'http://0.0.0.0/secret', + 'http://[::1]/secret', + 'http://[0:0:0:0:0:0:0:1]/secret', + 'http://[::]/secret', + 'http://[::ffff:127.0.0.1]/secret', + 'http://[::ffff:10.0.0.1]/secret', + 'http://[::127.0.0.1]/secret', + 'http://[::ffff:0:127.0.0.1]/secret', + 'http://[fe80::1]/secret', + 'http://[fe91::1]/secret', + 'http://[febf::1]/secret', + 'http://[fc00::1]/secret', + 'http://[fd12::1]/secret', + 'http://2130706433/secret', + 'http://0x7f000001/secret', + 'http://0177.0.0.1/secret', + ])('rejects private/internal network URL: %s', async (url) => { + await expect(directUrlDocumentLoader.loadMissingDocument(url, 'schema')) + .rejects.toThrow('private or internal network'); + }); }); \ No newline at end of file diff --git a/shared/src/document-loader/direct-url-document-loader.ts b/shared/src/document-loader/direct-url-document-loader.ts index 145ff1d2c..840028857 100644 --- a/shared/src/document-loader/direct-url-document-loader.ts +++ b/shared/src/document-loader/direct-url-document-loader.ts @@ -1,4 +1,5 @@ import axios, { Axios } from 'axios'; +import { isIPv4 } from 'net'; import { SchemaDirectory } from '../schema-directory'; import { CalmDocumentType, DocumentLoader } from './document-loader'; import { DocumentLoadError } from './document-loader'; @@ -44,14 +45,22 @@ export class DirectUrlDocumentLoader implements DocumentLoader { async loadMissingDocument(documentId: string, _type: CalmDocumentType): Promise { try { const parsedUrl = new URL(documentId); - const allowedProtocols = ['http:', 'https:']; - if (!allowedProtocols.includes(parsedUrl.protocol)) { + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { throw new DocumentLoadError({ name: 'UNKNOWN', message: `Unsupported URL protocol '${parsedUrl.protocol}' in document URL. Only HTTP and HTTPS are allowed.`, }); } - const response = await this.ax.get(parsedUrl.toString()); + if (isPrivateHost(parsedUrl.hostname)) { + throw new DocumentLoadError({ + name: 'UNKNOWN', + message: 'Requests to private or internal network addresses are not allowed.', + }); + } + // Reconstruct a safe URL from validated components. Disable redirects to prevent + // SSRF via 3xx responses that redirect to internal/private addresses. + const safeUrl = parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname + parsedUrl.search; + const response = await this.ax.get(safeUrl, { maxRedirects: 0 }); return response.data; } catch (error) { if (error instanceof DocumentLoadError) { @@ -72,3 +81,100 @@ export class DirectUrlDocumentLoader implements DocumentLoader { return undefined; } } + +/** + * Returns true if the given hostname resolves to a private, loopback, link-local, + * or otherwise non-public network address. + * + * NOTE: This is a string-based check on the literal hostname value. It does NOT + * protect against DNS rebinding attacks, where a public-looking hostname later + * resolves to a private IP address. For stronger protection, consider resolving + * the hostname to IP addresses and validating each resolved address. + */ +function isPrivateHost(hostname: string): boolean { + // URL.hostname includes brackets for IPv6 literals (e.g. "[::1]"); strip them for matching. + // Also strip a trailing dot (e.g. "localhost.") and normalise to lowercase. + const normalized = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); + + if (normalized === 'localhost') return true; + + // IPv4 private/reserved ranges + if (isIPv4(normalized)) { + const parts = normalized.split('.').map(Number); + return ( + parts[0] === 127 || // 127.0.0.0/8 loopback + parts[0] === 10 || // 10.0.0.0/8 private + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 private + (parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16 private + (parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 link-local + parts[0] === 0 // 0.0.0.0/8 "this network" + ); + } + + // IPv6 addresses (URL.hostname has already normalised these to compressed hex form) + if (normalized.includes(':')) { + const canonical = canonicalizeIPv6(normalized); + if (!canonical) return false; + + const words = canonical.split(':'); + + // ::1 loopback and :: unspecified address + if (canonical === '0000:0000:0000:0000:0000:0000:0000:0001') return true; + if (canonical === '0000:0000:0000:0000:0000:0000:0000:0000') return true; + + // Detect IPv4 addresses embedded in IPv6: + // IPv4-mapped ::ffff:x.x.x.x → words[5]='ffff', words[0..4]='0000' + // IPv4-compatible ::x.x.x.x → words[0..5]='0000' + // IPv4-translated ::ffff:0:x.x.x.x → words[4]='ffff', words[5]='0000', words[0..3]='0000' + // The URL constructor converts dotted-decimal embedded IPv4 to hex groups, so all three + // forms arrive here as pure hex (e.g. ::ffff:127.0.0.1 → ::ffff:7f00:1). + const isV4Mapped = words[5] === 'ffff' && words.slice(0, 5).every(w => w === '0000'); + const isV4Compatible = words.slice(0, 6).every(w => w === '0000'); + const isV4Translated = words[4] === 'ffff' && words[5] === '0000' && words.slice(0, 4).every(w => w === '0000'); + if (isV4Mapped || isV4Compatible || isV4Translated) { + const hi = parseInt(words[6], 16); + const lo = parseInt(words[7], 16); + const ipv4 = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + return isPrivateHost(ipv4); + } + + const firstWord = parseInt(words[0], 16); + // fe80::/10 link-local (fe80 through febf) + if (firstWord >= 0xfe80 && firstWord <= 0xfebf) return true; + // fc00::/7 unique local (fc00 through fdff) + if (firstWord >= 0xfc00 && firstWord <= 0xfdff) return true; + + return false; + } + + return false; +} + +/** + * Expands a (possibly compressed) IPv6 address string into its full + * 8-group colon-separated hex representation, e.g. "::1" → "0000:…:0001". + * Returns null if the input is not a valid IPv6 address. + */ +function canonicalizeIPv6(addr: string): string | null { + // Strip zone ID (e.g. "fe80::1%eth0") + const zoneIdx = addr.indexOf('%'); + const clean = zoneIdx >= 0 ? addr.substring(0, zoneIdx) : addr; + + const parts = clean.split('::'); + if (parts.length > 2) return null; + + let groups: string[]; + if (parts.length === 2) { + const left = parts[0] ? parts[0].split(':') : []; + const right = parts[1] ? parts[1].split(':') : []; + const missing = 8 - left.length - right.length; + if (missing < 0) return null; + groups = [...left, ...Array(missing).fill('0'), ...right]; + } else { + groups = clean.split(':'); + } + + if (groups.length !== 8) return null; + if (!groups.every(g => /^[0-9a-f]{1,4}$/i.test(g))) return null; + return groups.map(g => g.padStart(4, '0')).join(':'); +}