Route push endpoint guarding through RestrictedHTTP::PrivateNetworkGuard

The endpoint SSRF check already delegated its private-network classification
to surfguard, but called Surfguard.resolve_public_ips directly rather than
through RestrictedHTTP::PrivateNetworkGuard -- the hostname-in, address-out
shim #241 established as the app's single guarded-outbound entry point and
that Opengraph::Fetch resolves through. Route push resolution through the same
guard so once-campfire keeps one place that resolves and classifies outbound
addresses. Behavior is unchanged: the guard raises Violation when a permitted
host resolves only to blocked addresses (previously an empty list -> nil) and
propagates Surfguard::Unresolvable for a host that resolves to nothing; both
map to a nil endpoint IP, which fails validation and skips delivery. The
allowlist, HTTPS/443 constraints, and per-delivery IP pinning are unchanged.
This commit is contained in:
Jeremy Daer
2026-08-25 21:21:01 -07:00
parent 262ac6be06
commit fc91855d62
+14 -8
View File
@@ -1,8 +1,12 @@
require "restricted_http/private_network_guard"
class Push::Subscription < ApplicationRecord
# Web push endpoints only ever point at a browser vendor's push service. An
# allowlist keeps a user-supplied endpoint from turning delivery into an SSRF
# sink, and pinning the resolved public IP on every delivery closes the
# DNS-rebinding gap the way Opengraph::Fetch does for unfurls.
# DNS-rebinding gap. The private-network check itself is the shared
# RestrictedHTTP::PrivateNetworkGuard (surfguard) -- the same hostname-in,
# address-out guard Opengraph::Fetch pins its unfurls to.
PERMITTED_ENDPOINT_HOSTS = %w[
jmt17.google.com
fcm.googleapis.com
@@ -24,14 +28,16 @@ class Push::Subscription < ApplicationRecord
# permitted push service or doesn't resolve to a public IP. Enforced here, not
# only at save time, so a row that predates validation (or was inserted around
# it) still can't drive delivery at a non-allowlisted or private target.
# Re-resolved on every call so each delivery pins a freshly looked-up address
# rather than trusting the host to still resolve the way it did at sign-up.
# Re-resolved on every call, through the shared guard, so each delivery pins a
# freshly looked-up public address rather than trusting the host to still
# resolve the way it did at sign-up.
def resolved_endpoint_ip
Surfguard.resolve_public_ips(endpoint_uri.host).first if permitted_endpoint_uri?
rescue Surfguard::Unresolvable
# A host that resolves to nothing has no usable public IP -- the same outcome
# as one whose only addresses are blocked: no endpoint IP to pin, which fails
# endpoint validation. Push has no lookup-failed surface to distinguish.
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
# No usable public address: the host resolves only to blocked (private)
# addresses (Violation) or to nothing at all (Unresolvable). Either way there
# is no endpoint IP to pin, which fails endpoint validation. Push has no
# lookup-failed surface to distinguish the two.
nil
end