Skip to content

Commit e476093

Browse files
authored
feat: add strict policy option for enforcing base URL (#817)
Signed-off-by: Yordis Prieto <[email protected]>
1 parent 0178c14 commit e476093

File tree

2 files changed

+275
-12
lines changed

2 files changed

+275
-12
lines changed

lib/tesla/middleware/base_url.ex

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,34 @@ defmodule Tesla.Middleware.BaseUrl do
22
@moduledoc """
33
Set base URL for all requests.
44
5-
The base URL will be prepended to request path/URL only
6-
if it does not include http(s).
5+
By default, the base URL will be prepended to request path/URL only
6+
if it does not include http(s). Use the `policy: :strict` option to
7+
enforce base URL prepending regardless of scheme presence.
8+
9+
## Options
10+
11+
The options can be passed as a keyword list or a string representing the base URL.
12+
13+
- `:base_url` - The base URL to use for all requests.
14+
- `:policy` - Can be set to `:strict` to enforce base URL prepending even when
15+
the request URL already includes a scheme. Useful for security when the URL is
16+
controlled by user input. Defaults to `:insecure`.
17+
18+
> ### Security Considerations {: .warning}
19+
> When URLs are controlled by user input, always use `policy: :strict` to prevent
20+
> URL redirection attacks. The default `:insecure` policy allows users to bypass
21+
> the base URL by providing fully qualified URLs.
722
823
## Examples
924
1025
```elixir
1126
defmodule MyClient do
1227
def client do
1328
Tesla.client([
14-
{Tesla.Middleware.BaseUrl, "https://example.com/foo"}
29+
# Using keyword format (recommended)
30+
{Tesla.Middleware.BaseUrl, base_url: "https://example.com/foo"}
31+
# or alternatively, using string
32+
# {Tesla.Middleware.BaseUrl, "https://example.com/foo"}
1533
])
1634
end
1735
end
@@ -28,25 +46,77 @@ defmodule Tesla.Middleware.BaseUrl do
2846
# equals to GET https://example.com/foo
2947
3048
Tesla.get(client, "http://example.com/bar")
31-
# equals to GET http://example.com/bar
49+
# equals to GET http://example.com/bar (scheme detected, base URL not prepended)
50+
51+
# Using strict policy for user-controlled URLs (security)
52+
defmodule MySecureClient do
53+
def client do
54+
Tesla.client([
55+
{Tesla.Middleware.BaseUrl, base_url: "https://example.com/foo", policy: :strict}
56+
])
57+
end
58+
end
59+
60+
secure_client = MySecureClient.client()
61+
62+
Tesla.get(secure_client, "http://example.com/bar")
63+
# equals to GET https://example.com/foo/http://example.com/bar (base URL always prepended)
64+
65+
Tesla.get(secure_client, "/safe/path")
66+
# equals to GET https://example.com/foo/safe/path
3267
```
3368
"""
3469

3570
@behaviour Tesla.Middleware
3671

72+
@type policy :: :strict | :insecure
73+
@type opts :: [base_url: String.t(), policy: policy] | String.t()
74+
3775
@impl Tesla.Middleware
38-
def call(env, next, base) do
76+
@spec call(Tesla.Env.t(), Tesla.Env.stack(), opts()) :: Tesla.Env.result()
77+
def call(env, next, opts) do
78+
{base_url, opts} = parse_opts!(opts)
79+
3980
env
40-
|> apply_base(base)
81+
|> apply_base(base_url, opts)
4182
|> Tesla.run(next)
4283
end
4384

44-
defp apply_base(env, base) do
45-
if Regex.match?(~r/^https?:\/\//i, env.url) do
46-
# skip if url is already with scheme
47-
env
48-
else
49-
%{env | url: join(base, env.url)}
85+
defp parse_opts!(opts) when is_binary(opts) do
86+
{opts, []}
87+
end
88+
89+
defp parse_opts!(opts) when is_list(opts) do
90+
case Keyword.pop(opts, :base_url) do
91+
{base_url, remaining_opts} when is_binary(base_url) ->
92+
{base_url, remaining_opts}
93+
94+
{base_url, _remaining_opts} ->
95+
raise ArgumentError, "base_url must be a string but got #{inspect(base_url)}"
96+
end
97+
end
98+
99+
defp apply_base(env, base_url, opts) do
100+
case get_policy!(opts) do
101+
:strict ->
102+
%{env | url: join(base_url, env.url)}
103+
104+
:insecure ->
105+
if Regex.match?(~r/^https?:\/\//i, env.url) do
106+
env
107+
else
108+
%{env | url: join(base_url, env.url)}
109+
end
110+
end
111+
end
112+
113+
defp get_policy!(opts) do
114+
case Keyword.get(opts, :policy, :insecure) do
115+
policy when policy in [:strict, :insecure] ->
116+
policy
117+
118+
other ->
119+
raise ArgumentError, "invalid policy #{inspect(other)}, expected :strict or :insecure"
50120
end
51121
end
52122

test/tesla/middleware/base_url_test.exs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,197 @@ defmodule Tesla.Middleware.BaseUrlTest do
7979
assert {:ok, env} = @middleware.call(%Env{url: "HTTPS://other.foo"}, [], "http://example.com")
8080
assert env.url == "HTTPS://other.foo"
8181
end
82+
83+
test "strict policy: prepend base url even with http scheme" do
84+
assert {:ok, env} =
85+
@middleware.call(
86+
%Env{url: "http://other.foo"},
87+
[],
88+
base_url: "http://example.com",
89+
policy: :strict
90+
)
91+
92+
assert env.url == "http://example.com/http://other.foo"
93+
end
94+
95+
test "strict policy: prepend base url even with https scheme" do
96+
assert {:ok, env} =
97+
@middleware.call(
98+
%Env{url: "https://other.foo"},
99+
[],
100+
base_url: "http://example.com",
101+
policy: :strict
102+
)
103+
104+
assert env.url == "http://example.com/https://other.foo"
105+
end
106+
107+
test "strict policy: still works with relative paths" do
108+
assert {:ok, env} =
109+
@middleware.call(%Env{url: "/path"}, [],
110+
base_url: "http://example.com",
111+
policy: :strict
112+
)
113+
114+
assert env.url == "http://example.com/path"
115+
end
116+
117+
test "strict policy: case insensitive scheme detection" do
118+
assert {:ok, env} =
119+
@middleware.call(
120+
%Env{url: "HTTP://other.foo"},
121+
[],
122+
base_url: "http://example.com",
123+
policy: :strict
124+
)
125+
126+
assert env.url == "http://example.com/HTTP://other.foo"
127+
128+
assert {:ok, env} =
129+
@middleware.call(
130+
%Env{url: "HTTPS://other.foo"},
131+
[],
132+
base_url: "http://example.com",
133+
policy: :strict
134+
)
135+
136+
assert env.url == "http://example.com/HTTPS://other.foo"
137+
end
138+
139+
test "default policy (no policy): respects permissive behavior" do
140+
assert {:ok, env} =
141+
@middleware.call(%Env{url: "http://other.foo"}, [], base_url: "http://example.com")
142+
143+
assert env.url == "http://other.foo"
144+
end
145+
146+
test "backward compatibility: string base url works with new implementation" do
147+
assert {:ok, env} = @middleware.call(%Env{url: "http://other.foo"}, [], "http://example.com")
148+
assert env.url == "http://other.foo"
149+
150+
assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "http://example.com")
151+
assert env.url == "http://example.com/path"
152+
end
153+
154+
test "policy validation: accepts valid policy values" do
155+
assert {:ok, env} =
156+
@middleware.call(
157+
%Env{url: "http://other.foo"},
158+
[],
159+
base_url: "http://example.com",
160+
policy: :strict
161+
)
162+
163+
assert env.url == "http://example.com/http://other.foo"
164+
165+
assert {:ok, env} =
166+
@middleware.call(
167+
%Env{url: "http://other.foo"},
168+
[],
169+
base_url: "http://example.com",
170+
policy: :insecure
171+
)
172+
173+
assert env.url == "http://other.foo"
174+
end
175+
176+
test "policy validation: raises error for invalid policy values" do
177+
assert_raise ArgumentError, "invalid policy :strikt, expected :strict or :insecure", fn ->
178+
@middleware.call(
179+
%Env{url: "http://other.foo"},
180+
[],
181+
base_url: "http://example.com",
182+
policy: :strikt
183+
)
184+
end
185+
186+
assert_raise ArgumentError, "invalid policy :secure, expected :strict or :insecure", fn ->
187+
@middleware.call(
188+
%Env{url: "http://other.foo"},
189+
[],
190+
base_url: "http://example.com",
191+
policy: :secure
192+
)
193+
end
194+
195+
assert_raise ArgumentError, "invalid policy \"strict\", expected :strict or :insecure", fn ->
196+
@middleware.call(
197+
%Env{url: "http://other.foo"},
198+
[],
199+
base_url: "http://example.com",
200+
policy: "strict"
201+
)
202+
end
203+
204+
assert_raise ArgumentError, "invalid policy 123, expected :strict or :insecure", fn ->
205+
@middleware.call(
206+
%Env{url: "http://other.foo"},
207+
[],
208+
base_url: "http://example.com",
209+
policy: 123
210+
)
211+
end
212+
end
213+
214+
test "edge case: empty string base_url" do
215+
assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "")
216+
assert env.url == "/path"
217+
218+
assert {:ok, env} = @middleware.call(%Env{url: "path"}, [], "")
219+
assert env.url == "path"
220+
221+
assert {:ok, env} = @middleware.call(%Env{url: ""}, [], "")
222+
assert env.url == ""
223+
224+
assert {:ok, env} = @middleware.call(%Env{url: "http://example.com"}, [], "")
225+
assert env.url == "http://example.com"
226+
227+
assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], base_url: "")
228+
assert env.url == "/path"
229+
end
230+
231+
test "edge case: empty string base_url with strict policy" do
232+
assert {:ok, env} =
233+
@middleware.call(
234+
%Env{url: "http://example.com"},
235+
[],
236+
base_url: "",
237+
policy: :strict
238+
)
239+
240+
assert env.url == "http://example.com"
241+
242+
assert {:ok, env} =
243+
@middleware.call(
244+
%Env{url: "/path"},
245+
[],
246+
base_url: "",
247+
policy: :strict
248+
)
249+
250+
assert env.url == "/path"
251+
end
252+
253+
test "error handling: invalid base_url types" do
254+
assert_raise ArgumentError, "base_url must be a string but got nil", fn ->
255+
@middleware.call(%Env{url: "/path"}, [], base_url: nil)
256+
end
257+
258+
assert_raise ArgumentError, "base_url must be a string but got :invalid", fn ->
259+
@middleware.call(%Env{url: "/path"}, [], base_url: :invalid)
260+
end
261+
262+
assert_raise ArgumentError, "base_url must be a string but got 123", fn ->
263+
@middleware.call(%Env{url: "/path"}, [], base_url: 123)
264+
end
265+
266+
# Missing :base_url key (same error as nil)
267+
assert_raise ArgumentError, "base_url must be a string but got nil", fn ->
268+
@middleware.call(%Env{url: "/path"}, [], policy: :strict)
269+
end
270+
271+
assert_raise ArgumentError, "base_url must be a string but got nil", fn ->
272+
@middleware.call(%Env{url: "/path"}, [], [])
273+
end
274+
end
82275
end

0 commit comments

Comments
 (0)