Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 15 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/otomi-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1778,15 +1778,15 @@ 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 () => {
;(getAuthenticatedGitClient as jest.Mock).mockRejectedValue(new Error('Invalid credentials'))

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 () => {
Expand Down
8 changes: 5 additions & 3 deletions src/otomi-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
CasLubbers marked this conversation as resolved.
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' }
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/utils/codeRepoUtils.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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
}
})
}
Comment on lines +116 to +130

// Resolves hostname and rejects it if any resolved address matches CODE_REPO_BLOCKED_CIDRS.
export async function assertResolvesToPublicAddress(hostname: string): Promise<void> {
let addresses: { address: string }[]
try {
addresses = await lookup(hostname, { all: true })
Comment on lines +133 to +136
} 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 {
Expand Down Expand Up @@ -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)
}
Comment on lines +198 to +203

const gitOptions: Partial<SimpleGitOptions> = {
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')
Expand Down
5 changes: 5 additions & 0 deletions src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading