Skip to content

Commit 2df1689

Browse files
authored
fix: refuse an avatar_url that points at the runner's own network (#20)
What comes back from an avatar fetch is base64-embedded into a file the run then commits, so where it came from is a policy question rather than a transport detail. `safe_redirect` already treated it as one — but it only ever saw the second request onward. The first URL, the one written in the config, was fetched with nothing looking at it: a loopback address, a private range, a cloud metadata endpoint. Whatever answered was labelled `image/png` regardless of its real content type and embedded. On a workflow that builds a mural from a pull request, that address is the contributor's to choose. Verified end to end before the fix: a config naming a local server had that server's JSON response base64-embedded in the committed SVG, exit code 0. After, the fetch is refused, nothing is written, and the run fails with the reason. Two holes in the existing redirect rule went with it: - A host was judged by how it was spelled rather than by where it points, so a name aimed at an internal address walked past — as did the decimal form of one, since `2130706433` is not a literal to `Socket::IPAddress` but is 127.0.0.1 to the resolver. Names are resolved now, and every address they answer with is judged. Answers are cached per host, so a wall of a few thousand faces asks the resolver once per host rather than once per face. - `URI#host` keeps the brackets around an IPv6 literal, so `[::1]` was read as an unrecognised hostname and allowed. `#hostname` is the one that strips them. This raises the bar rather than sealing it, and the comment says so: the connection resolves the name again for itself, so a name that answers differently each time can still get through. Closing that means connecting to an already-vetted address and carrying the `Host` header by hand, which is a different piece of work. The reason a face is missing now reaches the log. Every failure — a 404, a timeout, a refused address — was reported as "avatar could not be fetched", which does not tell anyone whether to fix a typo, a permission or a URL. The fetcher had already worked it out; `Embedder` was dropping it on the floor. This is what makes the new rule diagnosable rather than mysterious for anyone whose avatar host really is internal. Note for review: that last case is a behaviour change for a self-hosted runner pulling avatars from an internal host — GitHub Enterprise Server, say. It fails now, with a message naming the address. If that is worth supporting, an opt-out input is the shape, and I would rather it be your call than my guess.
1 parent 9eea1f3 commit 2df1689

7 files changed

Lines changed: 187 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ and the generated files.
2626
mural. The specs now parse and validate every config the README shows, render
2727
every committed example config, and check that a run emits exactly the
2828
outputs `action.yml` declares.
29+
- An `avatar_url` pointing at the runner's own network is refused instead of
30+
fetched. Redirects were already held to that rule, but the first request was
31+
never checked, so an address written straight into the config — a cloud
32+
metadata endpoint, a service on the host — was fetched, labelled `image/png`
33+
whatever came back, and base64-embedded into a file the run then commits. On a
34+
workflow that builds a mural from a pull request, that address is the
35+
contributor's to choose. The check now also judges a host by the addresses it
36+
resolves to rather than by how it is spelled, which is what a name pointed at
37+
an internal address, or the decimal form of one, used to walk past — and
38+
`[::1]` no longer slips through the redirect rule either, where the brackets
39+
were being read as part of a hostname.
40+
- A skipped avatar says why. Every failure — a 404, a timeout, a refused address
41+
— was reported as "avatar could not be fetched", which does not tell anyone
42+
whether to fix a typo, a permission, or a URL. The fetcher already worked out
43+
the reason; now it survives as far as the log.
2944
- A control character in a name, role or section title no longer costs the whole
3045
file. XML carries three of them and no parser will read a document holding any
3146
of the others, but `HTML.escape` leaves them alone because in HTML they are

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,11 @@ bin/contributor-mural -c my.yml --commit # opt in to commit/push l
799799

800800
`--config` is resolved against the current directory, while output paths and local
801801
`avatar_url` files are resolved against `--workspace` (the current directory by default;
802-
`GITHUB_WORKSPACE` inside the action). Committing happens automatically when
802+
`GITHUB_WORKSPACE` inside the action). A local `avatar_url` has to stay inside the
803+
workspace, and a remote one has to be a public address: whatever comes back is embedded
804+
in a file the run commits, so an `avatar_url` pointing at the runner's own network —
805+
loopback, a private range, or a metadata endpoint — is refused rather than published.
806+
Committing happens automatically when
803807
`GITHUB_ACTIONS=true` — including on runners that emulate it, such as act or Forgejo —
804808
and otherwise only with `--commit`.
805809

spec/embedder_spec.cr

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ describe ContributorMural::Embedder do
7474
embedded, skipped = embedder.embed(users, grid_renderer, fail_on_missing: false)
7575

7676
embedded.map(&.login).should eq(["alpha"])
77-
skipped.should eq(["bravo"])
77+
skipped.map(&.login).should eq(["bravo"])
78+
# The reason travels with the login. Reported as "could not be fetched" on
79+
# its own, a refused address, a 404 and a timeout all read the same, and none
80+
# of them tells the reader which one they are looking at.
81+
skipped.first.reason.should contain("404")
7882
end
7983

8084
it "raises on failure when fail_on_missing is set" do

spec/github/avatar_fetcher_spec.cr

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ private def with_test_server(&)
3131
when "/redirect-internal"
3232
context.response.status_code = 302
3333
context.response.headers["Location"] = "https://127.0.0.1/secret.png"
34+
when "/redirect-internal-v6"
35+
# The bracketed form: `URI#host` keeps the brackets, so this used to be
36+
# read as a hostname nobody recognised and allowed straight through.
37+
context.response.status_code = 302
38+
context.response.headers["Location"] = "https://[::1]/secret.png"
3439
when "/missing"
3540
context.response.status_code = 404
3641
when "/flaky"
@@ -165,7 +170,7 @@ describe ContributorMural::HTTPAvatarSource do
165170
describe "#fetch" do
166171
it "returns bytes and content type" do
167172
with_test_server do |base, _requests|
168-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
173+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
169174
bytes, content_type = source.fetch(user_with("#{base}/ok.png"), 64)
170175
String.new(bytes).should eq("JPEGDATA")
171176
content_type.should eq("image/jpeg")
@@ -194,7 +199,7 @@ describe ContributorMural::HTTPAvatarSource do
194199

195200
it "does not retry a 404" do
196201
with_test_server do |base, requests|
197-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
202+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
198203
error = expect_raises(ContributorMural::AvatarError, /404/) do
199204
source.fetch(user_with("#{base}/missing"), 64)
200205
end
@@ -205,7 +210,7 @@ describe ContributorMural::HTTPAvatarSource do
205210

206211
it "falls back to image/png for malformed or non-image content types" do
207212
with_test_server do |base, _requests|
208-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
213+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
209214
# `@@@` makes MIME parsing raise; `text/html` is simply not an image.
210215
_, content_type = source.fetch(user_with("#{base}/bad-mime"), 64)
211216
content_type.should eq("image/png")
@@ -216,7 +221,7 @@ describe ContributorMural::HTTPAvatarSource do
216221

217222
it "refuses redirects that leave https or target internal addresses" do
218223
with_test_server do |base, _requests|
219-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
224+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
220225
expect_raises(ContributorMural::AvatarError, /non-https avatar redirect/) do
221226
source.fetch(user_with("#{base}/redirect"), 64)
222227
end
@@ -228,7 +233,7 @@ describe ContributorMural::HTTPAvatarSource do
228233

229234
it "retries server errors and succeeds" do
230235
with_test_server do |base, requests|
231-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
236+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
232237
bytes, _ = source.fetch(user_with("#{base}/flaky"), 64)
233238
String.new(bytes).should eq("RECOVERED")
234239
requests.count("/flaky").should eq(3)
@@ -240,7 +245,7 @@ describe ContributorMural::HTTPAvatarSource do
240245
# the status is under 500, so the fetch failed for good and that person
241246
# quietly went missing from the mural.
242247
with_scripted_server([429, 429, 200], retry_after: "0") do |url, attempts|
243-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
248+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
244249
bytes, _ = source.fetch(user_with(url), 64)
245250
String.new(bytes).should eq("SCRIPTED")
246251
attempts.call.should eq(3)
@@ -249,7 +254,7 @@ describe ContributorMural::HTTPAvatarSource do
249254

250255
it "gives up on sustained throttling with the status intact" do
251256
with_scripted_server([429], retry_after: "0") do |url, attempts|
252-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
257+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
253258
error = expect_raises(ContributorMural::AvatarError, /429/) do
254259
source.fetch(user_with(url), 64)
255260
end
@@ -260,14 +265,82 @@ describe ContributorMural::HTTPAvatarSource do
260265

261266
it "does not retry a status that will not heal" do
262267
with_scripted_server([418]) do |url, attempts|
263-
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
268+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
264269
expect_raises(ContributorMural::AvatarError, /418/) do
265270
source.fetch(user_with(url), 64)
266271
end
267272
attempts.call.should eq(1)
268273
end
269274
end
270275
end
276+
277+
# What comes back from an avatar fetch is base64-embedded into a file the run
278+
# commits, so the address it came from is a policy question. Redirects were
279+
# already held to it; the first request was not, and on a workflow that builds
280+
# a mural from a pull request that address is the contributor's to choose.
281+
describe "where it will fetch from" do
282+
it "refuses an internal address given as the avatar_url itself" do
283+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
284+
{"127.0.0.1", "169.254.169.254", "10.0.0.1", "192.168.1.1", "[::1]", "0.0.0.0"}.each do |host|
285+
error = expect_raises(ContributorMural::AvatarError, /runner's own network/) do
286+
source.fetch(user_with("http://#{host}/secret.png"), 64)
287+
end
288+
# 400 rather than a retryable status: this will read the same way in a
289+
# second, and the message is the whole answer.
290+
error.status.should eq(400)
291+
end
292+
end
293+
294+
it "refuses localhost by name" do
295+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
296+
expect_raises(ContributorMural::AvatarError, /runner's own network/) do
297+
source.fetch(user_with("https://localhost/secret.png"), 64)
298+
end
299+
end
300+
301+
# Spelling is not the question — where the name points is. A literal-only
302+
# check let the decimal form of a loopback address straight through.
303+
it "refuses a name that resolves to an internal address" do
304+
decimal = begin
305+
Socket::Addrinfo.resolve("2130706433", 80, type: Socket::Type::STREAM)
306+
.any?(&.ip_address.loopback?)
307+
rescue Socket::Error
308+
false
309+
end
310+
311+
if decimal
312+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
313+
expect_raises(ContributorMural::AvatarError, /runner's own network/) do
314+
source.fetch(user_with("http://2130706433/secret.png"), 64)
315+
end
316+
else
317+
# Some resolvers (musl) do not accept the decimal form at all, which
318+
# closes this door by itself.
319+
decimal.should be_false
320+
end
321+
end
322+
323+
it "still refuses a redirect that lands on an internal address" do
324+
with_test_server do |base, _requests|
325+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds, allow_local_targets: true)
326+
{"/redirect-internal", "/redirect-internal-v6"}.each do |path|
327+
expect_raises(ContributorMural::AvatarError, /internal address/) do
328+
source.fetch(user_with("#{base}#{path}"), 64)
329+
end
330+
end
331+
end
332+
end
333+
334+
it "leaves a public host alone" do
335+
# Resolves, is not internal, and the fetch fails on the request rather
336+
# than on the policy — which is what a real avatar host has to do.
337+
source = ContributorMural::HTTPAvatarSource.new(backoff_base: 0.seconds)
338+
error = expect_raises(ContributorMural::AvatarError) do
339+
source.fetch(user_with("https://example.invalid/avatar.png"), 64)
340+
end
341+
error.message.to_s.should_not match(/runner's own network/)
342+
end
343+
end
271344
end
272345

273346
private def response_with(retry_after : String?) : HTTP::Client::Response

src/contributor_mural/embedder.cr

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,29 @@ module ContributorMural
2727
fetch_missing(jobs.uniq!(&.url))
2828
end
2929

30-
# Returns users with embedded avatars (input order preserved) and the
31-
# logins skipped due to fetch failures. Raises the first failure instead
32-
# when `fail_on_missing` is set.
30+
# One person left out of the mural, and why. The reason travels with the
31+
# login because the caller is what reports it, and "could not be fetched"
32+
# on its own does not tell anyone whether to fix a typo, a permission, or
33+
# an address — the fetcher already worked that out.
34+
record Skipped, login : String, reason : String
35+
36+
# Returns users with embedded avatars (input order preserved) and the people
37+
# skipped due to fetch failures. Raises the first failure instead when
38+
# `fail_on_missing` is set.
3339
def embed(users : Array(ResolvedUser), renderer : Renderer,
34-
fail_on_missing : Bool) : {Array(EmbeddedUser), Array(String)}
40+
fail_on_missing : Bool) : {Array(EmbeddedUser), Array(Skipped)}
3541
jobs = jobs_for(users, renderer)
3642
fetch_missing(jobs.uniq(&.url))
3743

3844
embedded = [] of EmbeddedUser
39-
skipped = [] of String
45+
skipped = [] of Skipped
4046
jobs.each do |job|
4147
case result = @cache[job.url]
4248
in String
4349
embedded << EmbeddedUser.new(job.user, result)
4450
in AvatarError
4551
raise AvatarError.new("#{job.user.login}: #{result.message}", result.status) if fail_on_missing
46-
skipped << job.user.login
52+
skipped << Skipped.new(job.user.login, result.message || "avatar could not be fetched")
4753
end
4854
end
4955
{embedded, skipped}

src/contributor_mural/github/avatar_fetcher.cr

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ module ContributorMural
5959

6060
ALLOWED_CONTENT_TYPES = CONTENT_TYPES.values.to_set
6161

62-
# `allow_local_redirects` exists for specs, which redirect between
63-
# 127.0.0.1 ports; production keeps redirects on public https only.
62+
# Both flags exist for specs, which fetch from and redirect between
63+
# 127.0.0.1 ports; production keeps redirects on public https only and
64+
# refuses to fetch from the runner's own network at all.
65+
# `allow_local_redirects` implies `allow_local_targets`, since a spec that
66+
# follows a local redirect had to reach the local server first.
6467
def initialize(@workspace : String = Dir.current, @backoff_base : Time::Span = 1.second,
65-
@allow_local_redirects : Bool = false, pool : HTTPPool? = nil)
68+
@allow_local_redirects : Bool = false, pool : HTTPPool? = nil,
69+
allow_local_targets : Bool? = nil)
70+
@allow_local_targets = allow_local_targets.nil? ? @allow_local_redirects : allow_local_targets
6671
@pool = pool || HTTPPool.new
72+
# Answered once per host: a mural of a few thousand faces asks about the
73+
# same two or three, and the answer involves the resolver.
74+
@internal_hosts = {} of String => Bool
6775
# Only the UA: an `Accept` narrower than the wild card would let an
6876
# avatar host we have never heard of answer 406 where it used to work.
6977
@headers = HTTP::Headers{"User-Agent" => USER_AGENT}
@@ -148,6 +156,7 @@ module ContributorMural
148156
end
149157

150158
private def get_following_redirects(url : String) : {Bytes, String}
159+
refuse_internal_target(url)
151160
MAX_REDIRECTS.times do
152161
response = @pool.get(url, @headers)
153162
case response.status_code
@@ -188,7 +197,7 @@ module ContributorMural
188197
unless target.scheme == "https"
189198
raise AvatarError.new("refusing non-https avatar redirect to #{target}", 400)
190199
end
191-
host = target.host
200+
host = target.hostname
192201
raise AvatarError.new("avatar redirect without a host: #{target}", 400) unless host
193202
if internal_host?(host)
194203
raise AvatarError.new("refusing avatar redirect to internal address #{host}", 400)
@@ -198,10 +207,62 @@ module ContributorMural
198207
raise AvatarError.new("invalid avatar redirect target: #{ex.message}", 400)
199208
end
200209

210+
# The first URL is the one nobody was vetting. `safe_redirect` only ever saw
211+
# the second request onward, so an `avatar_url` written straight at the
212+
# runner's own network — a cloud metadata endpoint, a service on the host —
213+
# was fetched, labelled `image/png` whatever came back, and base64-embedded
214+
# into a file the run then commits. On a workflow that builds a mural from a
215+
# pull request, the address is the contributor's to choose.
216+
private def refuse_internal_target(url : String) : Nil
217+
return if @allow_local_targets
218+
# `hostname`, not `host`: the latter keeps the brackets around an IPv6
219+
# literal, and `[::1]` is not an address any of this recognises.
220+
host = URI.parse(url).hostname
221+
# No host means nothing to reach: a workspace-relative avatar never gets
222+
# here, it is read off disk.
223+
return unless host
224+
return unless internal_host?(host)
225+
raise AvatarError.new("refusing to fetch an avatar from #{host}" \
226+
"it is an address on the runner's own network", 400)
227+
rescue ex : URI::Error
228+
raise AvatarError.new("invalid avatar URL #{url.inspect}: #{ex.message}", 400)
229+
end
230+
201231
private def internal_host?(host : String) : Bool
232+
cached = @internal_hosts[host]?
233+
return cached unless cached.nil?
234+
verdict = resolves_internal?(host)
235+
@internal_hosts[host] = verdict
236+
verdict
237+
end
238+
239+
# Judged on the addresses the name actually answers with, not on how it is
240+
# spelled. Testing the literal alone let a hostname pointed at an internal
241+
# address walk past — as did the decimal form of one, since `2130706433` is
242+
# not a literal to `Socket::IPAddress` but is 127.0.0.1 to the resolver.
243+
#
244+
# This raises the bar rather than sealing it: the connection resolves the
245+
# name again for itself, so a name that answers differently each time can
246+
# still get through. Closing that means connecting to an address already
247+
# vetted and carrying the `Host` header along by hand.
248+
private def resolves_internal?(host : String) : Bool
202249
return true if host.compare("localhost", case_insensitive: true).zero?
203-
address = Socket::IPAddress.new(host, 0) rescue nil
204-
return false unless address
250+
if literal = (Socket::IPAddress.new(host, 0) rescue nil)
251+
return internal_address?(literal)
252+
end
253+
254+
addresses =
255+
begin
256+
Socket::Addrinfo.resolve(host, 80, type: Socket::Type::STREAM)
257+
rescue Socket::Error
258+
# A name that does not resolve is not a verdict to make here; let the
259+
# request fail where the error can say what actually happened.
260+
return false
261+
end
262+
addresses.any? { |info| internal_address?(info.ip_address) }
263+
end
264+
265+
private def internal_address?(address : Socket::IPAddress) : Bool
205266
address.loopback? || address.private? || address.link_local? || address.unspecified?
206267
end
207268
end

src/contributor_mural/runner.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ module ContributorMural
101101
private def draw(path : String, renderer : Renderer, users : Array(ResolvedUser),
102102
embedder : Embedder, warned : Set(String)) : {String, Int32}
103103
embedded, skipped = embedder.embed(users, renderer, @config.fail_on_missing?)
104-
skipped.each do |login|
105-
Annotations.warning("skipped #{login}: avatar could not be fetched") if warned.add?(login)
104+
skipped.each do |skip|
105+
Annotations.warning("skipped #{skip.login}: #{skip.reason}") if warned.add?(skip.login)
106106
end
107107
if embedded.empty?
108108
raise AvatarError.new("no avatars could be fetched — refusing to write an empty #{path}")

0 commit comments

Comments
 (0)