fix(pipelock): exempt declared ssh hosts from SSRF blocks

Pipelock's default SSRF blocklist includes 100.64.0.0/10 (RFC 6598
CGNAT, where Tailscale IPs live) plus all RFC 1918 / link-local
ranges, so a CONNECT to a bottle.ssh[] target on Tailscale was rejected
with `scanner: ssrf, reason: SSRF blocked: <ip> resolves to internal IP`
even after the host appeared in api_allowlist.

Fix: while emitting the YAML, classify each bottle.ssh[].Hostname:
  - IPv4 literal -> ssrf.ip_allowlist as <ip>/32 (canonical CIDR).
  - Hostname     -> trusted_domains (hostname-based SSRF exemption).

Both blocks are emitted only when entries exist, so bottles with no
ssh / no private-IP targets still produce a minimal config.

Assisted-by: Claude Code
This commit is contained in:
2026-05-08 01:42:31 -04:00
parent 8582e608af
commit 7d5f30763f
+70
View File
@@ -165,6 +165,50 @@ pipelock_bottle_ssh_hostnames() {
' "$manifest_file"
}
# _pipelock_is_ipv4_literal <s> — exit 0 if <s> looks like an IPv4
# literal (four dot-separated octets). Pipelock's SSRF check fires on
# the resolved IP, so a Hostname that's already an IP literal needs
# `ssrf.ip_allowlist`, while a hostname needs `trusted_domains`.
_pipelock_is_ipv4_literal() {
local s="${1:?}"
[[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# pipelock_bottle_ssh_trusted_domains <manifest> <bottle>
#
# Hostname-shaped ssh[].Hostname entries that should bypass pipelock's
# SSRF check (so a name resolving to a private IP — e.g. internal API
# behind a VPN — is reachable). IP-literal entries are excluded;
# trusted_domains is hostname-based per pipelock's docs.
pipelock_bottle_ssh_trusted_domains() {
local manifest_file="${1:?}"
local bottle_name="${2:?}"
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
_pipelock_is_ipv4_literal "$h" && continue
printf '%s\n' "$h"
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
}
# pipelock_bottle_ssh_ip_cidrs <manifest> <bottle>
#
# Emits one canonical /32 CIDR per IPv4-literal ssh[].Hostname so they
# pass pipelock's SSRF IP-range check (which blocks RFC 1918, RFC 6598
# CGNAT, link-local, loopback, etc. by default). Hostnames are skipped
# — they go through trusted_domains instead.
pipelock_bottle_ssh_ip_cidrs() {
local manifest_file="${1:?}"
local bottle_name="${2:?}"
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
if _pipelock_is_ipv4_literal "$h"; then
printf '%s/32\n' "$h"
fi
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
}
# pipelock_effective_allowlist <manifest_file> <bottle_name>
#
# Prints the deduplicated union of: the baked-in default allowlist, the
@@ -285,6 +329,32 @@ pipelock_write_yaml() {
printf 'forward_proxy:\n'
printf ' enabled: true\n'
printf '\n'
# SSRF exemptions for declared SSH hosts. Pipelock blocks the CGNAT
# range (100.64.0.0/10, where Tailscale IPs live) and the rest of
# RFC 1918 / link-local by default. Hostname entries go to
# trusted_domains; IP-literal entries to ssrf.ip_allowlist as /32.
local trusted_count=0 ssrf_count=0
local td
while IFS= read -r td; do
[ -z "$td" ] && continue
if [ "$trusted_count" -eq 0 ]; then
printf 'trusted_domains:\n'
fi
printf ' - "%s"\n' "$td"
trusted_count=$((trusted_count + 1))
done < <(pipelock_bottle_ssh_trusted_domains "$manifest_file" "$bottle_name")
[ "$trusted_count" -gt 0 ] && printf '\n'
local cidr
while IFS= read -r cidr; do
[ -z "$cidr" ] && continue
if [ "$ssrf_count" -eq 0 ]; then
printf 'ssrf:\n'
printf ' ip_allowlist:\n'
fi
printf ' - "%s"\n' "$cidr"
ssrf_count=$((ssrf_count + 1))
done < <(pipelock_bottle_ssh_ip_cidrs "$manifest_file" "$bottle_name")
[ "$ssrf_count" -gt 0 ] && printf '\n'
printf 'dlp:\n'
printf ' include_defaults: true\n'
printf ' scan_env: true\n'