From a7b1b19c34d96918329ab6029a732938a13459ac Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Fri, 9 Oct 2026 15:09:10 +0200 Subject: [PATCH 1/3] feat: enhance repository connection error handling and add public address resolution Signed-off-by: Cas Lubbers --- src/otomi-stack.ts | 8 +++-- src/utils/codeRepoUtils.ts | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 2f82bd45..595e0b21 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1598,15 +1598,17 @@ export default class OtomiStack { await git.listRemote([url]) return { status: 'success' } } catch (error) { - const message = error?.response?.data?.message || error?.message - return { status: 'failed', message } + const errorMessage = error?.response?.data?.message || error?.message + debug('Error testing repo connection:', errorMessage) + return { status: 'failed', message: 'Unable to connect to the repository' } } finally { if (keyPath && (await pathExists(keyPath))) { await unlink(keyPath) } } } catch (error) { - return { status: 'failed', message: error?.message } + debug('Error testing repo connection:', error?.message) + return { status: 'failed', message: 'Unable to connect to the repository' } } } diff --git a/src/utils/codeRepoUtils.ts b/src/utils/codeRepoUtils.ts index 05bb774a..a64421cf 100644 --- a/src/utils/codeRepoUtils.ts +++ b/src/utils/codeRepoUtils.ts @@ -1,5 +1,6 @@ /* eslint-disable prefer-destructuring */ import axios from 'axios' +import { lookup } from 'dns/promises' import { writeFile } from 'fs/promises' import { simpleGit, SimpleGit, SimpleGitOptions } from 'simple-git' import { OtomiError } from 'src/error' @@ -108,6 +109,57 @@ export function normalizeSSHKey(sshPrivateKey) { return `-----BEGIN OPENSSH PRIVATE KEY-----\n${basePrivateKey}\n-----END OPENSSH PRIVATE KEY-----` } +function ipv4ToLong(ip: string): number { + return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0 +} + +const PRIVATE_IPV4_RANGES: [string, number][] = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +] + +function isPrivateIPv4(ip: string): boolean { + const long = ipv4ToLong(ip) + return PRIVATE_IPV4_RANGES.some(([base, bits]) => { + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0 + return (long & mask) === (ipv4ToLong(base) & mask) + }) +} + +function isPrivateIPv6(ip: string): boolean { + const normalized = ip.toLowerCase() + if (normalized === '::1' || normalized === '::') return true + if (normalized.startsWith('fe80:') || normalized.startsWith('fec0:')) return true + if (/^f[cd][0-9a-f]{2}:/.test(normalized)) return true + const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) + if (mapped) return isPrivateIPv4(mapped[1]) + return false +} + +// Resolves hostname and rejects it if any resolved address is private/loopback/link-local/metadata. +export async function assertResolvesToPublicAddress(hostname: string): Promise { + let addresses: { address: string; family: number }[] + try { + addresses = await lookup(hostname, { all: true }) + } catch { + throw new Error('Unable to resolve repository host') + } + if (addresses.length === 0) throw new Error('Unable to resolve repository host') + const blocked = addresses.some(({ address, family }) => + family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address), + ) + if (blocked) throw new Error('Repository host resolves to a disallowed network address') +} + export function isInternalGiteaUrl(repositoryUrl: string, clusterDomainSuffix?: string) { if (!clusterDomainSuffix) return false try { @@ -160,8 +212,16 @@ export async function getAuthenticatedGitClient( throw new Error('Invalid URL provided') } + if (!isInternalGiteaUrl(normalizedUrl, domainSuffix)) { + const hostname = isSSH + ? normalizedUrl.slice('git@'.length, normalizedUrl.indexOf(':')) + : new URL(normalizedUrl).hostname + await assertResolvesToPublicAddress(hostname) + } + const gitOptions: Partial = { allowEnvironment: isSSH ? ['GIT_TERMINAL_PROMPT', 'GIT_SSH_COMMAND'] : ['GIT_TERMINAL_PROMPT'], + config: ['http.followRedirects=false'], ...(isSSH ? { unsafe: { allowUnsafeSshCommand: true } } : {}), } const git: SimpleGit = simpleGit(gitOptions).env('GIT_TERMINAL_PROMPT', '0') From 51ef9c14b383468531b0554216ba4583ee6143f1 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Fri, 9 Oct 2026 15:31:56 +0200 Subject: [PATCH 2/3] fix: tests Signed-off-by: Cas Lubbers --- src/otomi-stack.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 408c5d88..11a9ab93 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -1778,7 +1778,7 @@ describe('getTestRepoConnect', () => { const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') - expect(result).toEqual({ status: 'failed', message: 'Connection refused' }) + expect(result).toEqual({ status: 'failed', message: 'Unable to connect to the repository' }) }) it('should return { status: "failed" } when getAuthenticatedGitClient throws', async () => { @@ -1786,7 +1786,7 @@ describe('getTestRepoConnect', () => { const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') - expect(result).toEqual({ status: 'failed', message: 'Invalid credentials' }) + expect(result).toEqual({ status: 'failed', message: 'Unable to connect to the repository' }) }) it('should clean up the SSH key file after testing connection', async () => { From 909306d846193ca5a288ef7c6db146cb81e06380 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Fri, 9 Oct 2026 16:20:27 +0200 Subject: [PATCH 3/3] feat: enhance repository connection error handling and add public address resolution Signed-off-by: Cas Lubbers --- package-lock.json | 36 +++++++++------------- package.json | 1 + src/utils/codeRepoUtils.ts | 63 ++++++++++++++------------------------ src/validators.ts | 5 +++ 4 files changed, 44 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d71aa73..eadb346b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "fs-extra": "11.4.1", "generate-password": "1.7.1", "glob": "13.0.6", + "ipaddr.js": "^2.2.0", "jose": "^6.2.12", "jsonpath": "1.3.0", "jsonwebtoken": "9.0.3", @@ -7055,14 +7056,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/@linode/api-v4/node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "engines": { - "node": ">= 10" - } - }, "node_modules/@linode/api-v4/node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7089,15 +7082,6 @@ "yup": "^1.4.0" } }, - "node_modules/@linode/validation/node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -13026,11 +13010,12 @@ "license": "BSD-3-Clause" }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-array-buffer": { @@ -16637,6 +16622,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", diff --git a/package.json b/package.json index bb686498..0dfe6f83 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "fs-extra": "11.4.1", "generate-password": "1.7.1", "glob": "13.0.6", + "ipaddr.js": "^2.2.0", "jose": "^6.2.12", "jsonpath": "1.3.0", "jsonwebtoken": "9.0.3", diff --git a/src/utils/codeRepoUtils.ts b/src/utils/codeRepoUtils.ts index a64421cf..b31146af 100644 --- a/src/utils/codeRepoUtils.ts +++ b/src/utils/codeRepoUtils.ts @@ -2,12 +2,16 @@ import axios from 'axios' import { lookup } from 'dns/promises' import { writeFile } from 'fs/promises' +import ipaddr from 'ipaddr.js' import { simpleGit, SimpleGit, SimpleGitOptions } from 'simple-git' import { OtomiError } from 'src/error' import { v4 as uuidv4 } from 'uuid' import { getAuthenticatedUrl } from '../git/connect' import { getSecretValues } from '../k8s-operations' import { APL_SECRETS_NAMESPACE, GITEA_SECRETS_NAME } from '../constants' +import { cleanEnv, CODE_REPO_BLOCKED_CIDRS } from '../validators' + +const env = cleanEnv({ CODE_REPO_BLOCKED_CIDRS }) const axiosInstance = (adminUsername, adminPassword, domainSuffix) => axios.create({ @@ -109,55 +113,34 @@ export function normalizeSSHKey(sshPrivateKey) { return `-----BEGIN OPENSSH PRIVATE KEY-----\n${basePrivateKey}\n-----END OPENSSH PRIVATE KEY-----` } -function ipv4ToLong(ip: string): number { - return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0 -} - -const PRIVATE_IPV4_RANGES: [string, number][] = [ - ['0.0.0.0', 8], - ['10.0.0.0', 8], - ['100.64.0.0', 10], - ['127.0.0.0', 8], - ['169.254.0.0', 16], - ['172.16.0.0', 12], - ['192.0.0.0', 24], - ['192.168.0.0', 16], - ['198.18.0.0', 15], - ['224.0.0.0', 4], - ['240.0.0.0', 4], -] - -function isPrivateIPv4(ip: string): boolean { - const long = ipv4ToLong(ip) - return PRIVATE_IPV4_RANGES.some(([base, bits]) => { - const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0 - return (long & mask) === (ipv4ToLong(base) & mask) - }) -} - -function isPrivateIPv6(ip: string): boolean { - const normalized = ip.toLowerCase() - if (normalized === '::1' || normalized === '::') return true - if (normalized.startsWith('fe80:') || normalized.startsWith('fec0:')) return true - if (/^f[cd][0-9a-f]{2}:/.test(normalized)) return true - const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) - if (mapped) return isPrivateIPv4(mapped[1]) - return false +// Fully admin-controlled via CODE_REPO_BLOCKED_CIDRS +function isBlockedAddress(address: string): boolean { + const addr = ipaddr.process(address) // unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 + return env.CODE_REPO_BLOCKED_CIDRS.split(',') + .map((cidr) => cidr.trim()) + .filter(Boolean) + .some((cidr) => { + try { + const [rangeAddr, bits] = ipaddr.parseCIDR(cidr) + return addr.kind() === rangeAddr.kind() && addr.match(rangeAddr, bits) + } catch { + return false + } + }) } -// Resolves hostname and rejects it if any resolved address is private/loopback/link-local/metadata. +// Resolves hostname and rejects it if any resolved address matches CODE_REPO_BLOCKED_CIDRS. export async function assertResolvesToPublicAddress(hostname: string): Promise { - let addresses: { address: string; family: number }[] + let addresses: { address: string }[] try { addresses = await lookup(hostname, { all: true }) } catch { throw new Error('Unable to resolve repository host') } if (addresses.length === 0) throw new Error('Unable to resolve repository host') - const blocked = addresses.some(({ address, family }) => - family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address), - ) - if (blocked) throw new Error('Repository host resolves to a disallowed network address') + if (addresses.some(({ address }) => isBlockedAddress(address))) { + throw new Error('Repository host resolves to a disallowed network address') + } } export function isInternalGiteaUrl(repositoryUrl: string, clusterDomainSuffix?: string) { diff --git a/src/validators.ts b/src/validators.ts index 0d6b9625..90e57c06 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -38,6 +38,11 @@ export const VERSIONS = json({ }, }) export const CUSTOM_ROOT_CA = str({ desc: 'The root CA used for certs', default: undefined }) +export const CODE_REPO_BLOCKED_CIDRS = str({ + desc: 'Comma separated CIDRs that code repo URLs may not resolve to (SSRF protection).', + default: + '0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.168.0.0/16,198.18.0.0/15,224.0.0.0/4,240.0.0.0/4,::1/128,::/128,fe80::/10,fec0::/10,fc00::/7', +}) export const EDITOR_INACTIVITY_TIMEOUT = num({ desc: 'Inactivity timeout in days after which editor session is removed to clean mem', default: 1,