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
16 changes: 15 additions & 1 deletion lib/googly/generator/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ defmodule Googly.Generator.Endpoint do
name: name,
description: method[:description],
method: method[:httpMethod] |> String.downcase() |> String.to_atom(),
path: path,
path: host_relative_path(path),
required_parameters: required,
optional_parameters: optional,
path_parameters: Enum.filter(required, &(&1.location == "path")),
Expand All @@ -100,6 +100,20 @@ defmodule Googly.Generator.Endpoint do
}
end

# A discovery-document path (untrusted) must resolve *under* the API's own
# host. The generated client builds its request URL as `@base_url |>
# URI.merge(path)`, and RFC 3986 reference resolution lets a path that carries
# a scheme (`https:...`) or an authority (a leading `//`) REPLACE the base
# host — sending the caller's `Authorization: Bearer` token (and, for uploads,
# the payload) to an attacker-chosen host. Reduce the path to its host-relative
# component so it can only ever address the API's own host. Real Google paths
# are already host-relative, so this is a no-op for them (see `URI.parse/1`
# keeping `{+name}`/`:verb` templates intact).
defp host_relative_path(path) do
cleaned = URI.parse(path).path || "/"
"/" <> String.trim_leading(cleaned, "/")
end

defp data_param do
%Parameter{
name: "data",
Expand Down
75 changes: 75 additions & 0 deletions test/googly/generator/url_safety_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Googly.Generator.UrlSafetyTest do
# A discovery document is untrusted input (googly generates a client for *any*
# API). Method and media-upload *paths* are taken verbatim from it and become
# the request URL in the generated client via `@base_url |> URI.merge(path)`.
#
# RFC 3986 reference resolution says a reference that begins with "//" (a
# protocol-relative URL) or carries a scheme ("https:...") REPLACES the base
# host. So a crafted path can redirect an authenticated request to an
# attacker-controlled host — leaking the caller's `Authorization: Bearer
# <token>` (and, for uploads, the payload). Every generated path must be
# reduced to a host-relative one so it can only ever address the API's own host.
use ExUnit.Case, async: true

alias Googly.Generator.Endpoint
alias Googly.Generator.ResourceContext

@base "https://legit.googleapis.com/"

describe "malicious paths cannot hijack the request host (generation level)" do
test "a protocol-relative method path stays on the API host" do
assert on_host?(url_for(method("//attacker.example/exfil/{widgetId}")))
end

test "an absolute-URL method path stays on the API host" do
assert on_host?(url_for(method("https://attacker.example/exfil/{widgetId}")))
end

test "a protocol-relative media-upload path stays on the API host" do
m =
Map.merge(method("widgets"), %{
httpMethod: "POST",
request: %{"$ref": "Widget"},
mediaUpload: %{protocols: %{simple: %{path: "//attacker.example/upload"}}}
})

# from_method yields [basic, media, multipart]; every emitted url must be on-host.
urls = for ep <- Endpoint.from_method("insert", m, ctx()), do: merged(ep.path)
assert Enum.all?(urls, &on_host?/1), "off-host url emitted: #{inspect(urls)}"
end

test "a legitimate relative path is preserved and on-host (regression guard)" do
url = url_for(method("widgets/{widgetId}"))
assert on_host?(url)
assert URI.parse(url).path == "/widgets/{widgetId}"
end
end

# -- helpers ---------------------------------------------------------------

defp method(path) do
%{
httpMethod: "GET",
path: path,
parameterOrder: ["widgetId"],
parameters: %{"widgetId" => %{type: "string", location: "path", required: true}},
response: %{"$ref": "Widget"}
}
end

defp ctx do
ResourceContext.empty()
|> ResourceContext.with_namespace("Googly.Test")
|> ResourceContext.with_models_by_name(%{})
end

defp url_for(method) do
[ep] = Endpoint.from_method("get", method, ctx())
merged(ep.path)
end

# Reproduce the generated client's runtime URL construction.
defp merged(path), do: @base |> URI.merge(path) |> URI.to_string()

defp on_host?(url), do: URI.parse(url).host == URI.parse(@base).host
end
Loading