Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/merge-orchestrate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
- name: Setup Erlang/Elixir
uses: erlef/setup-beam@v1.24.1
with:
otp-version: '27.0'
otp-version: '27.3.4.19'
elixir-version: '1.17'

- name: Restore Mix cache
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
- name: Setup Erlang/Elixir
uses: erlef/setup-beam@v1.24.1
with:
otp-version: '27.0'
otp-version: '27.3.4.19'
elixir-version: '1.17'

- name: Restore Mix cache
Expand Down Expand Up @@ -91,7 +91,7 @@ jobs:
- name: Setup Erlang/Elixir
uses: erlef/setup-beam@v1.24.1
with:
otp-version: '27.0'
otp-version: '27.3.4.19'
elixir-version: '1.17'

- name: Restore Mix cache
Expand Down Expand Up @@ -418,7 +418,7 @@ jobs:
- name: Setup Erlang/Elixir
uses: erlef/setup-beam@v1.24.1
with:
otp-version: '27.0'
otp-version: '27.3.4.19'
elixir-version: '1.17'

- name: Install Elixir dependencies
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[tools]
# Language runtimes
# Match the OTP 27 / Elixir 1.17 test lane in .github/workflows/tests.yml.
erlang = "27.3.4.4"
erlang = "27.3.4.19" # >= 27.3.4.13: :httpc drops authorization on cross-origin redirect

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the security floor at the complete redirect fix

When a configured service URL embeds credentials (for example, HYPATIA_VERISIM_URL=http://user:pass@host), versions 27.3.4.13–18 still forward that userinfo to a cross-origin redirect; this remaining CVE-2026-48856 case was only fixed in 27.3.4.19, as documented in the OTP 27.3.4.19 release notes. Because Hypatia.ServiceUrl.present/1 accepts such URLs unchanged and the new test supplies only an explicit Authorization header, downgrading according to this >= 27.3.4.13 comment would make the test pass while credentials still leak. State 27.3.4.19 as the minimum and cover URL userinfo in the regression test.

Useful? React with 👍 / 👎.

elixir = "1.17.3-otp-27"
node = "latest"
rust = "latest" # provides cargo, rustfmt and clippy
Expand Down
125 changes: 125 additions & 0 deletions test/httpc_redirect_credentials_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# SPDX-License-Identifier: MPL-2.0

defmodule Hypatia.HttpcRedirectCredentialsTest do
# Guards the Erlang/OTP pin (mise.toml and the CI test lanes), not Hypatia's
# code. OTP releases before 27.3.4.13 make `:httpc` resend the
# `authorization` header to whatever host a redirect names; measured on
# 2026-11-09, 27.3.4.4 and 27.3.4.12 forwarded it to another port and to
# another host for 301, 302, 303, 307 and 308, GET and POST, while
# 27.3.4.13, 27.3.4.19 and 28.5.0.8 dropped it. The two echidnabot senders
# already set `autoredirect: false`; this test fails if the runtime itself
# regresses, so every other `:httpc` caller stays covered.
Comment on lines +10 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce redirect safety in credentialed callers

When Hypatia runs outside the newly pinned mise/workflow lanes, this does not keep every caller covered: the public Hypatia.Reviewer.review_pr/3 path sends GITHUB_TOKEN in lib/hypatia/reviewer.ex with httpc's default autoredirect: true, while the repository still documents and provisions OTP 28.3 in .github/workflows/ci.yml and .github/workflows/rsr-oracle.yml, a version the change itself identifies as vulnerable. An API redirect to another host in that supported runtime therefore forwards the token despite this test passing only in the pinned OTP 27 lanes; disable automatic redirects or implement origin-checked redirects in each credentialed caller rather than relying solely on development and CI runtime pins.

Useful? React with 👍 / 👎.

#
# Each case runs a redirector and a target on loopback. The redirector
# answers with STATUS and a Location on the target; the target reports the
# request line and any authorization header it receives.
use ExUnit.Case, async: true

@token "Bearer test-dummy-redirect-probe"

setup_all do
{:ok, _} = Application.ensure_all_started(:inets)
:ok
end

for status <- [301, 302, 303, 307, 308], method <- [:get, :post] do
test "#{method} #{status} to another origin drops the authorization header" do
{line, auth} = follow(unquote(method), unquote(status), :other_origin)
assert line =~ "/final"
assert auth == []
end
end

# Positive control: the target does see the header when the redirect stays
# on the same origin, so an empty list above is the runtime dropping it, not
# the harness failing to observe it.
test "same-origin redirect keeps the authorization header" do
{_line, auth} = follow(:get, 302, :same_origin)
assert auth == ["authorization: #{@token}"]
end

defp follow(method, status, where) do
{:ok, front} = listen()
{:ok, back} = listen()
{:ok, front_port} = :inet.port(front)
{:ok, back_port} = :inet.port(back)
target = if where == :same_origin, do: front, else: back
target_port = if where == :same_origin, do: front_port, else: back_port
location = "http://127.0.0.1:#{target_port}/final"
parent = self()

server =
spawn_link(fn ->
{:ok, s1} = :gen_tcp.accept(front, 5_000)
_ = read_request(s1)

:ok =
:gen_tcp.send(
s1,
"HTTP/1.1 #{status} Redirect\r\nLocation: #{location}\r\n" <>
"Content-Length: 0\r\nConnection: close\r\n\r\n"
)

:gen_tcp.close(s1)
{:ok, s2} = :gen_tcp.accept(target, 5_000)
[line | headers] = s2 |> read_request() |> String.split("\r\n")
auth = Enum.filter(headers, &String.starts_with?(String.downcase(&1), "authorization:"))
send(parent, {:seen, line, auth})
:gen_tcp.send(s2, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
:gen_tcp.close(s2)
end)

url = ~c"http://127.0.0.1:#{front_port}/start"
headers = [{~c"authorization", String.to_charlist(@token)}]

request =
case method do
:get -> {url, headers}
:post -> {url, headers, ~c"application/json", ~c"{}"}
end

{:ok, {{_, 200, _}, _, _}} =
:httpc.request(method, request, [timeout: 5_000, autoredirect: true], [])

assert_receive {:seen, line, auth}, 5_000
Process.unlink(server)
Enum.each([front, back], &:gen_tcp.close/1)
{line, auth}
end

defp listen do
:gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}])
end

# Reads up to the end of the headers, then any Content-Length body.
defp read_request(socket, acc \\ "") do
case :binary.match(acc, "\r\n\r\n") do
{pos, _} ->
head = binary_part(acc, 0, pos)
want = content_length(head)

if byte_size(acc) - pos - 4 >= want do
head
else
read_more(socket, acc)
end

:nomatch ->
read_more(socket, acc)
end
end

defp read_more(socket, acc) do
case :gen_tcp.recv(socket, 0, 5_000) do
{:ok, data} -> read_request(socket, acc <> data)
{:error, _} -> acc
end
end

defp content_length(head) do
case Regex.run(~r/content-length:\s*(\d+)/i, head) do
[_, n] -> String.to_integer(n)
nil -> 0
end
end
end
Loading