Skip to content

Commit 208c215

Browse files
committed
Add request step to escape request URLs
See #430.
1 parent 0ed0277 commit 208c215

4 files changed

Lines changed: 206 additions & 1 deletion

File tree

lib/req/steps.ex

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ defmodule Req.Steps do
3535
:compress_body,
3636
:checksum,
3737
:aws_sigv4,
38+
:escape_url,
3839

3940
# response steps
4041
:raw,
@@ -85,7 +86,8 @@ defmodule Req.Steps do
8586
put_plug: &Req.Steps.put_plug/1,
8687
compress_body: &Req.Steps.compress_body/1,
8788
checksum: &Req.Steps.checksum/1,
88-
put_aws_sigv4: &Req.Steps.put_aws_sigv4/1
89+
put_aws_sigv4: &Req.Steps.put_aws_sigv4/1,
90+
escape_url: &Req.Steps.escape_url/1
8991
)
9092
|> Req.Request.prepend_response_steps(
9193
retry: &Req.Steps.retry/1,
@@ -1321,6 +1323,86 @@ defmodule Req.Steps do
13211323
end
13221324
end
13231325

1326+
def escape_url(request) do
1327+
if Req.Request.get_option(request, :escape_url, true) do
1328+
request |> escape_host() |> escape_path() |> escape_query()
1329+
else
1330+
request
1331+
end
1332+
end
1333+
1334+
defp escape_host(request) when is_binary(request.url.host) do
1335+
with {:ok, host} <- decode_host_and_ensure_not_encoded_ip(request.url.host),
1336+
{:ok, host} <- encode_host(host, request.url.scheme) do
1337+
put_in(request.url.host, host)
1338+
else
1339+
:error -> raise ArgumentError, "invalid URL host: #{inspect(request.url.host)}"
1340+
end
1341+
end
1342+
1343+
defp escape_host(request), do: request
1344+
1345+
defp decode_host_and_ensure_not_encoded_ip(host) do
1346+
case :inet.parse_address(to_charlist(host)) do
1347+
{:ok, _} ->
1348+
{:ok, host}
1349+
1350+
{:error, _} ->
1351+
with decoded <- URI.decode(host),
1352+
{:error, _} <- :inet.parse_address(to_charlist(decoded)) do
1353+
{:ok, decoded}
1354+
else
1355+
# a non-IP host decoded to an IP address, this kind of scenario was a
1356+
# vulnerability in hackney: GHSA-pj7v
1357+
{:ok, _encoded_ip} -> :error
1358+
end
1359+
end
1360+
end
1361+
1362+
defp encode_host(host, "http+unix"), do: {:ok, host}
1363+
1364+
defp encode_host(host, _scheme) do
1365+
if host |> to_charlist |> List.ascii_printable?() do
1366+
{:ok, host}
1367+
else
1368+
{:ok, host |> :idna.encode() |> to_string()}
1369+
end
1370+
catch
1371+
:exit, {:bad_label, _} -> :error
1372+
end
1373+
1374+
defp escape_path(request) when is_binary(request.url.path) do
1375+
if escape_segment?(request.url.path) do
1376+
update_in(request.url.path, &URI.encode/1)
1377+
else
1378+
request
1379+
end
1380+
end
1381+
1382+
defp escape_path(request), do: request
1383+
1384+
defp escape_query(request) when is_binary(request.url.query) do
1385+
if escape_segment?(request.url.query) do
1386+
update_in(request.url.query, &URI.encode/1)
1387+
else
1388+
request
1389+
end
1390+
end
1391+
1392+
defp escape_query(request), do: request
1393+
1394+
defp escape_segment?(<<?%, octet::binary-size(2), rest::binary>>) do
1395+
case Base.decode16(octet) do
1396+
{:ok, _} -> escape_segment?(rest)
1397+
:error -> true
1398+
end
1399+
end
1400+
1401+
defp escape_segment?(<<?%, _rest::binary>>), do: true
1402+
defp escape_segment?(<<?\s, _rest::binary>>), do: true
1403+
defp escape_segment?(<<_, rest::binary>>), do: escape_segment?(rest)
1404+
defp escape_segment?(<<>>), do: false
1405+
13241406
## Response steps
13251407

13261408
@doc """

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ defmodule Req.MixProject do
9393
{:finch, "~> 0.21", finch_opts()},
9494
{:mime, "~> 2.0.6 or ~> 2.1"},
9595
{:jason, "~> 1.0"},
96+
{:idna, "~> 7.0"},
9697
{:nimble_csv, "~> 1.0", optional: true},
9798
{:plug, "~> 1.0", [optional: true] ++ plug_opts()},
9899
{:brotli, "~> 0.3.1", optional: true},

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"},
1111
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
1212
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
13+
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
1314
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
1415
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
1516
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},

test/req/steps_test.exs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,101 @@ defmodule Req.StepsTest do
10261026
end
10271027
end
10281028

1029+
describe "escape_url" do
1030+
test "can be disabled" do
1031+
resp =
1032+
Req.get!(
1033+
escape_url: false,
1034+
plug: &Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)),
1035+
url: "http://lüthje.com/with space"
1036+
)
1037+
1038+
assert resp.body == "http://lüthje.com/with space"
1039+
end
1040+
1041+
test "escapes host" do
1042+
resp =
1043+
Req.get!(
1044+
plug: &Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)),
1045+
url: "http://lüthje.com/"
1046+
)
1047+
1048+
assert resp.body == "http://xn--lthje-kva.com/"
1049+
end
1050+
1051+
test "escapes percent encoded host" do
1052+
resp =
1053+
Req.get!(
1054+
plug: &Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)),
1055+
url:
1056+
"http://www.%E3%81%BB%E3%82%93%E3%81%A8%E3%81%86%E3%81%AB%E3%81%AA%E3%81%8C%E3%81%84%E3%82%8F%E3%81%91%E3%81%AE%E3%82%8F%E3%81%8B%E3%82%89%E3%81%AA%E3%81%84%E3%81%A9%E3%82%81%E3%81%84%E3%82%93%E3%82%81%E3%81%84%E3%81%AE%E3%82%89%E3%81%B9%E3%82%8B%E3%81%BE%E3%81%A0%E3%81%AA%E3%81%8C%E3%81%8F%E3%81%97%E3%81%AA%E3%81%84%E3%81%A8%E3%81%9F%E3%82%8A%E3%81%AA%E3%81%84.w3.mag.keio.ac.jp/"
1057+
)
1058+
1059+
assert resp.body ==
1060+
"http://www.xn--n8jaaaaai5bhf7as8fsfk3jnknefdde3fg11amb5gzdb4wi9bya3kc6lra.w3.mag.keio.ac.jp/"
1061+
end
1062+
1063+
test "escapes percent encoded unix socket" do
1064+
resp =
1065+
Req.get!(
1066+
plug:
1067+
&Plug.Conn.send_resp(
1068+
&1,
1069+
200,
1070+
[to_string(&1.scheme), "://", &1.host, &1.request_path, "?", &1.query_string]
1071+
),
1072+
url: "http+unix://user@%2Fvar%2Frun%2Ftest.sock/path?key=value"
1073+
)
1074+
1075+
assert resp.body == "http+unix:///var/run/test.sock/path?key=value"
1076+
end
1077+
1078+
test "raises on non-IP host that decodes to an IP" do
1079+
assert_raise ArgumentError, ~r/invalid URL host: "%31%32%37%2E%30%2E%30%2E%31"/, fn ->
1080+
Req.get!(
1081+
plug: &Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)),
1082+
url: "http://%31%32%37%2E%30%2E%30%2E%31/"
1083+
)
1084+
end
1085+
end
1086+
1087+
test "escapes path" do
1088+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1089+
resp = Req.get!(req, url: "#{url}/with space")
1090+
assert resp.body == "#{url}/with%20space"
1091+
end
1092+
1093+
test "escapes unescaped path with %" do
1094+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1095+
resp = Req.get!(req, url: "#{url}/with%2percent")
1096+
assert resp.body == "#{url}/with%252percent"
1097+
end
1098+
1099+
test "keeps escaped path untouched" do
1100+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1101+
resp = Req.get!(req, url: "#{url}/with%20space")
1102+
assert resp.body == "#{url}/with%20space"
1103+
end
1104+
1105+
test "escapes query" do
1106+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1107+
resp = Req.get!(req, url: "#{url}/?with space")
1108+
assert resp.body == "#{url}/?with%20space"
1109+
end
1110+
1111+
test "escapes unescaped query with %" do
1112+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1113+
resp = Req.get!(req, url: "#{url}/?with%2percent")
1114+
assert resp.body == "#{url}/?with%252percent"
1115+
end
1116+
1117+
test "keeps escaped query untouched" do
1118+
%{req: req, url: url} = serve(&Plug.Conn.send_resp(&1, 200, Plug.Conn.request_url(&1)))
1119+
resp = Req.get!(req, url: "#{url}/?with%20space")
1120+
assert resp.body == "#{url}/?with%20space"
1121+
end
1122+
end
1123+
10291124
## Response steps
10301125

10311126
describe "decompress_body" do
@@ -1733,6 +1828,32 @@ defmodule Req.StepsTest do
17331828
end
17341829
end
17351830

1831+
@tag :capture_log
1832+
test "escapes location" do
1833+
%{req: req, url: url} =
1834+
serve(fn
1835+
conn when conn.request_path == "/redirect" ->
1836+
location =
1837+
case conn.query_string do
1838+
"" -> "/oh hai?param=with spaces"
1839+
string -> "/oh hai?" <> string
1840+
end
1841+
1842+
redirect(conn, 302, location)
1843+
1844+
conn ->
1845+
Plug.Conn.send_resp(conn, 200, Plug.Conn.request_url(conn))
1846+
end)
1847+
1848+
response = Req.get!(req, url: "#{url}/redirect")
1849+
assert response.status == 200
1850+
assert response.body == "#{url}/oh%20hai?param=with%20spaces"
1851+
1852+
response = Req.get!(req, url: "#{url}/redirect?a=already%20encoded")
1853+
assert response.status == 200
1854+
assert response.body == "#{url}/oh%20hai?a=already%20encoded"
1855+
end
1856+
17361857
test "without location" do
17371858
%{req: req, url: url} =
17381859
serve(fn conn ->

0 commit comments

Comments
 (0)