From 7d4a612dba4dbc7b2431b32aafbebf9779a3e26c Mon Sep 17 00:00:00 2001 From: Zeke Gabrielse Date: Thu, 23 Jul 2026 13:33:25 -0500 Subject: [PATCH 1/3] update address validation message and code --- app/validators/url_validator.rb | 4 ++-- spec/validators/url_validator_spec.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 46606da804..8578dd0fa8 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -32,7 +32,7 @@ def validate_each(record, attribute, value) record.errors.add attribute, :protocol_invalid, message: "must be a valid URL using one of the following protocols: #{protocols.join(", ")}" unless valid_protocol?(uri) record.errors.add attribute, :host_invalid, message: 'must be a URL with a valid host' unless valid_host?(uri) - record.errors.add attribute, :host_private, message: 'must not resolve to a private address' unless public_host?(uri) + record.errors.add attribute, :address_invalid, message: 'must resolve to a public address' unless public_address?(uri) rescue URI::InvalidURIError, URI::InvalidComponentError, URI::BadURIError @@ -73,7 +73,7 @@ def blacklisted_host?(host) end end - def public_host?(uri) + def public_address?(uri) addrs = Resolv.getaddresses(uri.host) return false if addrs.empty? diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index b518f78801..fd2001972b 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -124,11 +124,11 @@ def self.it_resolves_to(*addrs, valid:) else it { is_expected.to_not be_valid } - it 'should add a host_private error' do + it 'should add an invalid address error' do subject.validate expect(subject.errors.details[:url]).to include( - hash_including(error: :host_private), + hash_including(error: :address_invalid), ) end end From ba32b4af1759a4d62aa1b606cecf940ea90846af Mon Sep 17 00:00:00 2001 From: Zeke Gabrielse Date: Thu, 23 Jul 2026 13:39:53 -0500 Subject: [PATCH 2/3] add env var for private webhook address opt-in --- .env.sample | 7 +++++ app/validators/url_validator.rb | 15 ++++++++-- spec/validators/url_validator_spec.rb | 40 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.env.sample b/.env.sample index 7b9427af35..e74bf302fd 100644 --- a/.env.sample +++ b/.env.sample @@ -109,6 +109,13 @@ KEYGEN_HOST= # Set to -1 to disable pruning. # KEYGEN_PRUNE_WEBHOOK_BACKLOG_DAYS=30 +# Allow webhook and other configurable URLs to resolve to private, loopback, +# link-local, and other non-public addresses. By default these are rejected to +# prevent server-side request forgery (SSRF). Only enable this in trusted, +# self-hosted environments where pointing webhooks at internal services is +# intended. +# KEYGEN_ALLOW_PRIVATE_ADDRESSES=1 + # The number of rows pruned per-batch during pruning. # KEYGEN_PRUNE_BATCH_SIZE=10000 diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 8578dd0fa8..2a5368ee0a 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -32,7 +32,7 @@ def validate_each(record, attribute, value) record.errors.add attribute, :protocol_invalid, message: "must be a valid URL using one of the following protocols: #{protocols.join(", ")}" unless valid_protocol?(uri) record.errors.add attribute, :host_invalid, message: 'must be a URL with a valid host' unless valid_host?(uri) - record.errors.add attribute, :address_invalid, message: 'must resolve to a public address' unless public_address?(uri) + record.errors.add attribute, :address_invalid, message: 'must resolve to a valid address' unless valid_address?(uri) rescue URI::InvalidURIError, URI::InvalidComponentError, URI::BadURIError @@ -73,8 +73,17 @@ def blacklisted_host?(host) end end - def public_address?(uri) - addrs = Resolv.getaddresses(uri.host) + def valid_address?(uri) + # NB(ezekg) self-hosted deployments may legitimately point webhooks at private or + # internal addresses, so allow explicit opt-in. + return true if + ENV.true?('KEYGEN_ALLOW_PRIVATE_ADDRESSES') + + public_address?(uri.host) + end + + def public_address?(host) + addrs = Resolv.getaddresses(host) return false if addrs.empty? diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index fd2001972b..62a615ebf6 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -205,4 +205,44 @@ def self.it_resolves_to(*addrs, valid:) it { is_expected.to_not be_valid } end + + context 'when private addresses are allowed' do + with_env KEYGEN_ALLOW_PRIVATE_ADDRESSES: '1' do + context 'with a host resolving to a private address' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '10.0.0.1') } + + it { is_expected.to be_valid } + end + + context 'with a host resolving to a link-local address' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '169.254.169.254') } + + it { is_expected.to be_valid } + end + + context 'with an unresolvable host' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example') } + + it { is_expected.to be_valid } + end + + context 'with a blacklisted host' do + let(:url) { 'https://api.keygen.sh' } + + it { is_expected.to_not be_valid } + end + + context 'with an invalid protocol' do + let(:url) { 'ftp://ftp.example' } + + it { is_expected.to_not be_valid } + end + end + end end From e158daf61dce2876b321f7be8431fe63593d0d92 Mon Sep 17 00:00:00 2001 From: Zeke Gabrielse Date: Thu, 23 Jul 2026 13:56:41 -0500 Subject: [PATCH 3/3] update private address opt-in to use allowlist --- .env.sample | 15 ++++--- app/validators/url_validator.rb | 29 ++++++++----- spec/validators/url_validator_spec.rb | 60 +++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.env.sample b/.env.sample index e74bf302fd..845755f80a 100644 --- a/.env.sample +++ b/.env.sample @@ -109,12 +109,15 @@ KEYGEN_HOST= # Set to -1 to disable pruning. # KEYGEN_PRUNE_WEBHOOK_BACKLOG_DAYS=30 -# Allow webhook and other configurable URLs to resolve to private, loopback, -# link-local, and other non-public addresses. By default these are rejected to -# prevent server-side request forgery (SSRF). Only enable this in trusted, -# self-hosted environments where pointing webhooks at internal services is -# intended. -# KEYGEN_ALLOW_PRIVATE_ADDRESSES=1 +# A comma-separated list of CIDR ranges that webhook and other configurable +# URLs are allowed to resolve to. By default, private, loopback, link-local, +# and other non-public addresses are rejected to prevent server-side request +# forgery (SSRF). Leave empty to keep that secure default. To permit specific +# internal ranges, add them explicitly, e.g. an internal service subnet like +# 10.0.0.0/24 (prefer the narrowest range possible), or use 0.0.0.0/0,::/0 to +# allow all addresses. Only use this in trusted, self-hosted environments where +# pointing webhooks at internal services is intended. +# KEYGEN_ALLOWED_PRIVATE_ADDRESSES= # The number of rows pruned per-batch during pruning. # KEYGEN_PRUNE_BATCH_SIZE=10000 diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 2a5368ee0a..a12f29bdc2 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -74,21 +74,18 @@ def blacklisted_host?(host) end def valid_address?(uri) - # NB(ezekg) self-hosted deployments may legitimately point webhooks at private or - # internal addresses, so allow explicit opt-in. - return true if - ENV.true?('KEYGEN_ALLOW_PRIVATE_ADDRESSES') - - public_address?(uri.host) - end - - def public_address?(host) - addrs = Resolv.getaddresses(host) + addrs = Resolv.getaddresses(uri.host) return false if addrs.empty? addrs.all? do |addr| ip = IPAddr.new(addr) + + # NB(ezekg) self-hosted deployments may legitimately point webhooks at private or + # internal addresses, so allow explicitly permitted ranges. + next true if + allowed_private_address?(ip) + next false if ip.loopback? || ip.private? || ip.link_local? || ip.ipv4_mapped? || ip.ipv4_compat? @@ -107,6 +104,18 @@ def public_address?(host) false end + def allowed_private_address?(ip) = allowed_private_ranges.any? { it.include?(ip) } + def allowed_private_ranges + return [] unless + ENV.key?('KEYGEN_ALLOWED_PRIVATE_ADDRESSES') + + # NB(ezekg) a comma-separated CIDR allowlist of otherwise-private ranges to permit, + # e.g. an internal service subnet for webhooks. + ENV.fetch('KEYGEN_ALLOWED_PRIVATE_ADDRESSES') { '' } + .split(',') + .filter_map { IPAddr.new(it.strip) unless it.strip.empty? } + end + def blacklisted_ipv4?(ip) = BLACKLISTED_IPV4.any? { it.include?(ip) } def blacklisted_ipv6?(ip) = BLACKLISTED_IPV6.any? { it.include?(ip) } def embedded_ipv4?(ip) = NAT64_PREFIXES.any? { it.include?(ip) } diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index 62a615ebf6..78fc0dd67c 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -206,8 +206,8 @@ def self.it_resolves_to(*addrs, valid:) it { is_expected.to_not be_valid } end - context 'when private addresses are allowed' do - with_env KEYGEN_ALLOW_PRIVATE_ADDRESSES: '1' do + context 'when all addresses are allowed' do + with_env KEYGEN_ALLOWED_PRIVATE_ADDRESSES: '0.0.0.0/0, ::/0' do context 'with a host resolving to a private address' do let(:url) { 'https://webhooks.example' } @@ -229,7 +229,7 @@ def self.it_resolves_to(*addrs, valid:) before { stub_resolv!('webhooks.example') } - it { is_expected.to be_valid } + it { is_expected.to_not be_valid } end context 'with a blacklisted host' do @@ -245,4 +245,58 @@ def self.it_resolves_to(*addrs, valid:) end end end + + context 'when private addresses are restricted to an allowlist' do + with_env KEYGEN_ALLOWED_PRIVATE_ADDRESSES: '10.0.0.0/8, fd00::/8' do + context 'with a host resolving to an allowed private range' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '10.1.2.3') } + + it { is_expected.to be_valid } + end + + context 'with a host resolving to an allowed private IPv6 range' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', 'fd00::1') } + + it { is_expected.to be_valid } + end + + context 'with a host resolving to a private range outside the allowlist' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '192.168.1.1') } + + it { is_expected.to_not be_valid } + end + + context 'with a host resolving to a public address' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '93.184.215.14') } + + it { is_expected.to be_valid } + end + + context 'with a mix of allowed and disallowed private addresses' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '10.1.2.3', '192.168.1.1') } + + it { is_expected.to_not be_valid } + end + end + + context 'with a malformed allowlist entry' do + with_env KEYGEN_ALLOWED_PRIVATE_ADDRESSES: 'not-a-cidr' do + let(:url) { 'https://webhooks.example' } + + before { stub_resolv!('webhooks.example', '93.184.215.14') } + + it { is_expected.to_not be_valid } + end + end + end end