ssrfr guards one hop at a time:
ssrf_prepare_hop() decides whether a URL may be fetched and
ssrf_fetch() fetches it through a pinned connection; a
redirect is followed by preparing the next hop from the previous one, or
by ssrf_fetch_chain(), which runs that loop. The contract
it implements is design/specs/ssrfr-v1.md in the source
repository.
Every hop is evaluated under a policy. It is validated when it is built, so a malformed rule is an error rather than a rule that silently matches nothing:
policy <- ssrf_policy(allow_ranges = "10.0.0.0/8", deny_hosts = ".corp")
policy
#> <ssrfr_policy>
#> allow_schemes "http", "https"
#> allow_ports 80, 443
#> deny_hosts ".corp"
#> allow_hosts (none)
#> deny_ranges (none)
#> allow_ranges "10.0.0.0/8"
#> allow_userinfo FALSE
#> max_redirects 20
#> connect_timeout 3
#> total_timeout 30
#> max_response_size 10485760
#> max_header_bytes 16384
#> max_header_fields 128
#> max_url_length 8000
#> user_agent "ssrfr/0.0.0.9000 (+https://gitlab.com/bart-turczynski/ssrfr)"
try(ssrf_policy(deny_hosts = "com, ru"))
#> Error in ssrf_policy() :
#> `deny_hosts[1]` joins several values in one string; give each its own entry.The reason codes a refusal will carry are a closed, versioned vocabulary:
ssrf_vocabulary("reason_codes")$code
#> [1] "loopback" "private" "link-local"
#> [4] "cloud-metadata" "shared" "unspecified"
#> [7] "this-network" "ipv4-mapped" "ipv4-translated"
#> [10] "ipv4-compatible" "nat64" "6to4"
#> [13] "teredo" "isatap" "malformed-address"
#> [16] "numeric-literal" "scheme" "downgrade"
#> [19] "userinfo" "port" "host-denied"
#> [22] "range-denied" "parse" "multicast"
#> [25] "redirect-limit" "reserved"ssrf_inspect_url() reports what a URL is under a policy,
with no network I/O. It parses the URL as the guarded fetch will, with
rurl and then with libcurl’s own parser, and reports the
first check that applies: a parse the two parsers disagree on, the
scheme, embedded credentials, the port, an ambiguous numeric host, the
hostname rules, and, for a host written as an address, the address
rules.
ssrf_inspect_url("http://0177.0.0.1/")$code
#> [1] "numeric-literal"
ssrf_inspect_url("http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")
#> <ssrfr_inspection> (L0: facts, not a defense)
#> code: cloud-metadata
#> step: 8
#> detail:
#> gate: 2
#> tier: 1
#> category: protocol
#> embedding_kind: nat64_wk
#> provider_kind: instance-metadata
#> url: http://[64:ff9b::a9fe:a9fe]/latest/meta-data/
#> scheme: http
#> host: [64:ff9b::a9fe:a9fe]
#> host kind: ipv6
#> port: 80It is a pre-filter and a way to lint a configuration, not a defense. It resolves no name, so a name it finds nothing wrong with may still point anywhere:
ssrf_inspect_url("https://example.com/")$code
#> [1] NA
policy <- ssrf_policy(allow_ranges = "10.0.0.0/8")
ssrf_inspect_url("http://10.1.2.3/", policy)$code
#> [1] NAThe provider endpoints and metadata hostnames it refuses by default are vocabularies too, each row citing the vendor documentation that names it:
With layer = "L1", ssrf_inspect_url() also
asks the system resolver, once, for every address of a name the earlier
checks passed, and classifies each address with the addresses embedded
in it. The name is looked up as absolute, with a trailing root dot, so
http://intranet/ is never completed with a DNS search
domain. One refused address refuses the whole set, and each address
keeps its own facts. Were dual.example to resolve to
93.184.216.34 and ::1:
res <- ssrf_inspect_url("http://dual.example/", layer = "L1")
res$code
#> [1] "loopback"
vapply(res$addresses, function(a) a$code, "")
#> [1] NA "loopback"A resolver error, an empty answer or an answer that is not an address
is the operational cause "unresolvable", in
cause rather than code. A URL an earlier check
refuses is never looked up, so this makes no DNS query:
L1 is still not a defense: the answer can change the moment it returns. Only the guarded fetch, which connects to an address it has just validated, is.
A guarded hop has two steps. ssrf_prepare_hop() parses
the URL, checks it, resolves a name once and classifies every address,
and returns one of three values: a refusal (code), an
operational failure (cause), or a binding. Only a binding
can be fetched. Nothing below makes a network call: these URLs are
refused, or their host is an address, which needs no resolution.
policy <- ssrf_policy()
refusal <- ssrf_prepare_hop("http://169.254.169.254/latest/", policy,
request = list())
refusal$code
#> [1] "cloud-metadata"
ssrf_public_reason(refusal)
#> [1] "refused"
binding <- ssrf_prepare_hop(
"https://93.184.216.34/data?page=2#top",
policy,
request = list(headers = c(Authorization = "Bearer not-shown"))
)
binding
#> <ssrfr_binding> (hop 1, fetchable)
#> origin: https://93.184.216.34:443
#> url: https://93.184.216.34/data?page=2
#> validated: 93.184.216.34
#> pin: 93.184.216.34
#> method: GET
#> headers: Authorization (values withheld)
#> body: none
#> tls: certificate and hostname verifiedThe request plan is part of the decision, not of the fetch: a list of
method, headers, body and
carry, where list() is a plain
GET. A header the transport owns, such as
Host, or a metadata-service marker such as
Metadata-Flavor, is an error before anything is parsed:
try(ssrf_prepare_hop("https://example.com/", policy,
request = list(headers = c(`Metadata-Flavor` = "Google"))))
#> Error in ssrf_prepare_hop() :
#> `request$headers[1]` is a metadata-service request marker; it is refused unless the policy's `allow_ranges` names a provider endpoint exactly.ssrf_fetch() then connects only to an address the
binding validated. The connection is pinned with libcurl’s
connect_to, so the Host header, SNI and
certificate verification stay bound to the hostname, while the name is
never resolved again. The binding is single-use and spent on entry; the
response owns no connection and prints no body:
response <- ssrf_fetch(binding)
response
#> <ssrfr_response>
#> status: 200
#> type: text/html
#> body: 1256 bytes (read it with $body)
rawToChar(response$body)
binding$state$status
#> [1] 200
try(ssrf_fetch(binding))
#> Error : This binding was already fetched; prepare the hop again.A failure on the wire is a value, not an error: cause is
one of ssrf_vocabulary("causes"), and detail
lists the addresses tried and how each attempt ended. Like a refusal,
project it with ssrf_public_reason() before an untrusted
party sees it.
ssrf_fetch_chain() follows a whole redirect chain
through the guard and returns its last outcome: the final response, or
the refusal or failure that ended the chain.
policy <- ssrf_policy(max_redirects = 5)
result <- ssrf_fetch_chain(
"https://example.com/start",
policy,
request = list(headers = c(Authorization = "Bearer not-shown"))
)It is built on the two primitives and adds nothing to them. A
redirect is returned, not followed, by ssrf_fetch(): its
status is in the response and its Location in
binding$state$location. To follow it, prepare the next hop
with from = binding. The Location may be
relative; it is resolved against the previous hop’s URL, and the new URL
is checked from the start. The request plan is inherited, never
restated: a redirect to another origin drops the body and every header
field the plan did not nominate in carry, and
Authorization, Proxy-Authorization and
Cookie never cross. This is the loop
ssrf_fetch_chain() runs. It keeps only the last outcome, so
an application that must log every hop, as OWASP’s “log all accepted and
blocked network flows” asks, runs the loop itself and logs each hop’s
binding and outcome where the comment marks:
policy <- ssrf_policy(max_redirects = 5)
result <- ssrf_prepare_hop(
"https://example.com/start",
policy,
request = list(headers = c(Authorization = "Bearer not-shown"))
)
while (inherits(result, "ssrfr_binding")) {
hop <- result
result <- ssrf_fetch(hop)
# Log this hop here: `hop` is its spent binding, `result` its outcome.
followed <- inherits(result, "ssrfr_response") &&
result$status %in% c(301, 302, 303, 307, 308) &&
identical(hop$state$location_count, 1L)
if (!followed) {
break
}
result <- ssrf_prepare_hop(hop$state$location, policy, from = hop)
}
outcome <- if (inherits(result, "ssrfr_response")) {
result$body # the body's bytes, a raw vector, whatever they hold
} else if (inherits(result, "ssrfr_refusal")) {
result$code
} else {
result$cause
}The loop, like ssrf_fetch_chain(), ends with
result holding the chain’s final outcome: a response, a
refusal or a failure, whichever call returned it. A redirect hop can be
refused like any other: "downgrade" for https
to http, or any code a first hop can get. Log a refusal’s
code or a failure’s cause for the operator,
and show an untrusted party only
ssrf_public_reason(result). The url of a
redirect hop is the Location exactly as
binding$state$location holds it; a different URL is an
error, and starts a new chain instead. The chain’s
max_redirects and total_timeout are fixed by
its first hop: once max_redirects redirects have been
followed, ssrf_fetch() refuses the next 3xx as
"redirect-limit", and a policy that restates either budget
differently mid-chain is an error.
ssrfr guards only requests made through a binding. These
stay unguarded, and an application that lets an attacker influence the
URL must not use them for it:
download.file(), url(), and
readLines(), read.csv() or
readRDS() given a URL;curl calls
(curl::curl_fetch_memory(), curl::curl() and
the rest), and the curl command-line tool, whether through
system() or download.file(method = "curl"),
which also reads ~/.curlrc;jsonlite::fromJSON(url),
data.table::fread(url), xml2::read_xml(url)
and xml2::read_html(url).ssrfr is not egress control either: a firewall or a
separate network is the stronger boundary, and ssrfr is
defense in depth beside it.
A loopback server is itself a refused destination, so a test that fetches one through the guard reopens loopback in its own policy. There is no test mode and no switch that turns the guard off; keep this policy in tests: