diff --git a/package-lock.json b/package-lock.json index 8a20a4d9..28140394 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", @@ -13030,11 +13014,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": { @@ -16641,6 +16626,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 25c82382..7fd0379b 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/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 () => { 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..b31146af 100644 --- a/src/utils/codeRepoUtils.ts +++ b/src/utils/codeRepoUtils.ts @@ -1,12 +1,17 @@ /* eslint-disable prefer-destructuring */ 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({ @@ -108,6 +113,36 @@ export function normalizeSSHKey(sshPrivateKey) { return `-----BEGIN OPENSSH PRIVATE KEY-----\n${basePrivateKey}\n-----END OPENSSH PRIVATE KEY-----` } +// 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 matches CODE_REPO_BLOCKED_CIDRS. +export async function assertResolvesToPublicAddress(hostname: string): Promise { + 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') + if (addresses.some(({ address }) => isBlockedAddress(address))) { + 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 +195,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') 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,