Resolve API resource URLs from the request origin

3801479f18d7 · AtlantisPleb · · parent cc2aae44a9d4

Resolve API resource URLs from the request origin

Issue-family, label, and milestone JSON hardcoded
https://openagents.com, so a response generated on staging advertised
production URLs. A new RequestOrigin plug assigns :url_base from the conn
scheme, host, and port; Phoenix rewrites the scheme through rewrite_on
(:x_forwarded_proto) in production, and untrusted forwarded headers such as
X-Forwarded-Host are never consulted. The JSON renderers fall back to the
configured endpoint URL only when no conn assign exists.

Tests cover request-host reflection, forwarded-header rejection, and the
updated label and milestone URL expectations.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/controllers/label_json.ex
  • modified lib/openagents_web/controllers/milestone_json.ex
  • added lib/openagents_web/plugs/request_origin.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • modified test/openagents_web/controllers/label_controller_test.exs
  • modified test/openagents_web/controllers/milestone_controller_test.exs

Diff

8 files changed, +79 -6

lib/openagents_web/controllers/issue_json.ex modified +7 -2

@@ -18,6 +18,7 @@ defmodule OpenAgentsWeb.IssueJSON do

18 18
  defp issue_json(issue, assigns) do
19 19
    owner = Map.get(assigns, :owner, "OpenAgents")
20 20
    repo = Map.get(assigns, :repo, "openagents")
21
    url_base = url_base(assigns)
21 22
22 23
    %{
23 24
      id: issue.id,

@@ -36,11 +37,15 @@ defmodule OpenAgentsWeb.IssueJSON do

36 37
      created_at: issue.inserted_at,
37 38
      updated_at: issue.updated_at,
38 39
      closed_at: issue.closed_at,
39
      html_url: "https://openagents.com/#{owner}/#{repo}/issues/#{issue.number}",
40
      url: "https://openagents.com/api/v3/repos/#{owner}/#{repo}/issues/#{issue.number}"
40
      html_url: "#{url_base}/#{owner}/#{repo}/issues/#{issue.number}",
41
      url: "#{url_base}/api/v3/repos/#{owner}/#{repo}/issues/#{issue.number}"
41 42
    }
42 43
  end
43 44
45
  defp url_base(assigns) do
46
    Map.get(assigns, :url_base) || String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
47
  end
48
44 49
  defp translate_error({msg, opts}) do
45 50
    Regex.replace(~r/%{(\w+)}/, msg, fn _, key ->
46 51
      to_string(Keyword.get(opts, String.to_existing_atom(key), key))
lib/openagents_web/controllers/label_json.ex modified +5 -1

@@ -26,10 +26,14 @@ defmodule OpenAgentsWeb.LabelJSON do

26 26
      description: label.description,
27 27
      default: false,
28 28
      url:
29
        "https://openagents.com/api/v3/repos/#{owner}/#{repo}/labels/#{URI.encode(label.name, &URI.char_unreserved?/1)}"
29
        "#{url_base(assigns)}/api/v3/repos/#{owner}/#{repo}/labels/#{URI.encode(label.name, &URI.char_unreserved?/1)}"
30 30
    }
31 31
  end
32 32
33
  defp url_base(assigns) do
34
    Map.get(assigns, :url_base) || String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
35
  end
36
33 37
  defp translate_error({msg, opts}) do
34 38
    Regex.replace(~r/%{(\w+)}/, msg, fn _, key ->
35 39
      to_string(Keyword.get(opts, String.to_existing_atom(key), key))
lib/openagents_web/controllers/milestone_json.ex modified +5 -1

@@ -28,10 +28,14 @@ defmodule OpenAgentsWeb.MilestoneJSON do

28 28
      due_on: milestone.due_on,
29 29
      open_issues: milestone.open_issues,
30 30
      closed_issues: milestone.closed_issues,
31
      url: "https://openagents.com/api/v3/repos/#{owner}/#{repo}/milestones/#{milestone.number}"
31
      url: "#{url_base(assigns)}/api/v3/repos/#{owner}/#{repo}/milestones/#{milestone.number}"
32 32
    }
33 33
  end
34 34
35
  defp url_base(assigns) do
36
    Map.get(assigns, :url_base) || String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
37
  end
38
35 39
  defp translate_error({msg, opts}) do
36 40
    Regex.replace(~r/%{(\w+)}/, msg, fn _, key ->
37 41
      to_string(Keyword.get(opts, String.to_existing_atom(key), key))
lib/openagents_web/plugs/request_origin.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgentsWeb.Plugs.RequestOrigin do
2
  @moduledoc """
3
  Assigns `:url_base`, the origin the request actually arrived on.
4
5
  The value comes from the conn scheme, host, and port. Phoenix rewrites
6
  `conn.scheme` through trusted proxy headers such as `X-Forwarded-Proto`
7
  only when the endpoint declares `rewrite_on`, so untrusted forwarded
8
  headers cannot invent an origin here. API responses use this base to
9
  build resource URLs instead of advertising a hardcoded production host.
10
  """
11
12
  import Plug.Conn
13
14
  def init(options), do: options
15
16
  def call(conn, _options) do
17
    assign(conn, :url_base, base_url(conn))
18
  end
19
20
  defp base_url(conn) do
21
    case conn.port do
22
      port when port in [80, 443] ->
23
        "#{conn.scheme}://#{conn.host}"
24
25
      port ->
26
        "#{conn.scheme}://#{conn.host}:#{port}"
27
    end
28
  end
29
end
lib/openagents_web/router.ex modified +3

@@ -24,6 +24,7 @@ defmodule OpenAgentsWeb.Router do

24 24
25 25
  pipeline :api do
26 26
    plug :accepts, ["json"]
27
    plug OpenAgentsWeb.Plugs.RequestOrigin
27 28
  end
28 29
29 30
  pipeline :authenticated_api do

@@ -37,11 +38,13 @@ defmodule OpenAgentsWeb.Router do

37 38
38 39
  pipeline :forge_write_api do
39 40
    plug :accepts, ["json"]
41
    plug OpenAgentsWeb.Plugs.RequestOrigin
40 42
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
41 43
  end
42 44
43 45
  pipeline :optional_forge_api do
44 46
    plug :accepts, ["json"]
47
    plug OpenAgentsWeb.Plugs.RequestOrigin
45 48
    plug OpenAgentsWeb.Plugs.OptionalApiTokenAuth, scope: "forge:write"
46 49
  end
47 50
test/openagents_web/controllers/issue_controller_test.exs modified +28

@@ -138,6 +138,34 @@ defmodule OpenAgentsWeb.IssueControllerTest do

138 138
    end
139 139
  end
140 140
141
  describe "request origin URLs" do
142
    test "issue URLs reflect the request host", %{conn: conn} do
143
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "Origin issue"})
144
145
      conn =
146
        conn
147
        |> Map.replace!(:host, "staging.example.com")
148
        |> get(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues")
149
150
      assert %{"issues" => [issue]} = json_response(conn, 200)
151
152
      assert issue["html_url"] =~
153
               "http://staging.example.com/OpenAgentsInc/openagents.com/issues/"
154
    end
155
156
    test "a forwarded host header does not replace the request origin", %{conn: conn} do
157
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "Forwarded issue"})
158
159
      conn =
160
        conn
161
        |> put_req_header("x-forwarded-host", "evil.example.net")
162
        |> get(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues")
163
164
      assert %{"issues" => [issue]} = json_response(conn, 200)
165
      refute issue["html_url"] =~ "evil.example.net"
166
    end
167
  end
168
141 169
  defp repository do
142 170
    Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
143 171
  end
test/openagents_web/controllers/label_controller_test.exs modified +1 -1

@@ -26,7 +26,7 @@ defmodule OpenAgentsWeb.LabelControllerTest do

26 26
      assert %{"labels" => [label]} = json_response(conn, 200)
27 27
28 28
      assert label["url"] ==
29
               "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug"
29
               "http://www.example.com/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug"
30 30
    end
31 31
32 32
    test "GET /api/v3/repos/:owner/:repo/labels returns an empty list", %{conn: conn} do
test/openagents_web/controllers/milestone_controller_test.exs modified +1 -1

@@ -39,7 +39,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

39 39
      assert rendered["closed_issues"] == 1
40 40
41 41
      assert rendered["url"] ==
42
               "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}"
42
               "http://www.example.com/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}"
43 43
    end
44 44
45 45
    test "GET /api/v3/repos/:owner/:repo/milestones returns an empty list", %{conn: conn} do

This page updates live while a promote is in flight · changelog