Skip to content

Commit e7546b1

Browse files
committed
added wildcard subdomains for CORS allowed origins
Signed-off-by: Alejandro M. Ramallo <alejandro.ramallo@leapsight.com>
1 parent d1b5d99 commit e7546b1

3 files changed

Lines changed: 131 additions & 4 deletions

File tree

apps/bondy/src/bondy_http_cors.erl

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ configuration. Supports three origin modes:
1414
- `auto` — derives the allowed origin from the request's own
1515
scheme/host/port
1616
- `[binary()]` — an explicit allowlist of origins; the request
17-
`Origin` header is validated against this list
17+
`Origin` header is validated against this list. Entries starting
18+
with `*.` are treated as wildcard subdomain patterns (e.g.
19+
`*.example.com` matches `https://app.example.com`)
1820

1921
Configuration is read from the Bondy application environment at path
2022
`[ListenerName, cors]` using the listener ref from the Cowboy request.
@@ -112,7 +114,7 @@ effective_origin(Req, #{allowed_origins := AllowedList}) when is_list(AllowedLis
112114
undefined ->
113115
undefined;
114116
Origin ->
115-
case lists:member(Origin, AllowedList) of
117+
case origin_allowed(Origin, AllowedList) of
116118
true -> Origin;
117119
false -> undefined
118120
end
@@ -135,6 +137,56 @@ derive_origin(Req) ->
135137
end.
136138

137139

140+
%% @private
141+
origin_allowed(_Origin, []) ->
142+
false;
143+
144+
origin_allowed(Origin, [Origin | _]) ->
145+
true;
146+
147+
origin_allowed(Origin, [<<"*.", Rest/binary>> | Tail]) ->
148+
case origin_matches_wildcard(Origin, Rest) of
149+
true -> true;
150+
false -> origin_allowed(Origin, Tail)
151+
end;
152+
153+
origin_allowed(Origin, [_ | Tail]) ->
154+
origin_allowed(Origin, Tail).
155+
156+
157+
%% @private
158+
%% Matches "*.example.com" against an origin like "https://sub.example.com:443".
159+
%% Extracts the host from the origin and checks if it is a subdomain of the
160+
%% wildcard suffix.
161+
origin_matches_wildcard(Origin, DomainSuffix) ->
162+
case extract_host(Origin) of
163+
undefined ->
164+
false;
165+
Host ->
166+
%% Host must end with ".example.com" (the DomainSuffix)
167+
%% and must be strictly longer (i.e. have a subdomain part).
168+
SuffixWithDot = <<".", DomainSuffix/binary>>,
169+
SuffixLen = byte_size(SuffixWithDot),
170+
HostLen = byte_size(Host),
171+
HostLen > SuffixLen andalso
172+
binary:part(Host, HostLen - SuffixLen, SuffixLen) =:= SuffixWithDot
173+
end.
174+
175+
176+
%% @private
177+
%% Extracts the host part from an origin string like "https://host:port".
178+
extract_host(Origin) ->
179+
case binary:split(Origin, <<"://">>) of
180+
[_Scheme, Rest] ->
181+
case binary:split(Rest, <<":">>) of
182+
[Host, _Port] -> Host;
183+
[Host] -> Host
184+
end;
185+
_ ->
186+
undefined
187+
end.
188+
189+
138190
%% @private
139191
build_headers(<<"*">> = Origin, Config) ->
140192
#{

apps/bondy/test/bondy_http_cors_SUITE.erl

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ all() ->
3434
auto_default_http_port,
3535
auto_default_https_port,
3636
custom_methods_and_headers,
37-
custom_max_age
37+
custom_max_age,
38+
wildcard_subdomain_match,
39+
wildcard_subdomain_no_match,
40+
wildcard_subdomain_exact_domain_no_match,
41+
wildcard_subdomain_with_port,
42+
wildcard_subdomain_mixed_with_exact
3843
].
3944

4045

@@ -188,6 +193,68 @@ custom_max_age(_Config) ->
188193

189194

190195

196+
wildcard_subdomain_match(_Config) ->
197+
Allowed = [<<"*.example.com">>],
198+
Config = config(#{allowed_origins => Allowed}),
199+
Req = fake_req(<<"https://app.example.com">>),
200+
Headers = bondy_http_cors:headers(Req, Config),
201+
?assertEqual(
202+
<<"https://app.example.com">>,
203+
maps:get(<<"access-control-allow-origin">>, Headers)
204+
).
205+
206+
207+
wildcard_subdomain_no_match(_Config) ->
208+
Allowed = [<<"*.example.com">>],
209+
Config = config(#{allowed_origins => Allowed}),
210+
Req = fake_req(<<"https://evil.com">>),
211+
?assertEqual(#{}, bondy_http_cors:headers(Req, Config)).
212+
213+
214+
wildcard_subdomain_exact_domain_no_match(_Config) ->
215+
%% "*.example.com" should NOT match "https://example.com" itself —
216+
%% only subdomains like "sub.example.com"
217+
Allowed = [<<"*.example.com">>],
218+
Config = config(#{allowed_origins => Allowed}),
219+
Req = fake_req(<<"https://example.com">>),
220+
?assertEqual(#{}, bondy_http_cors:headers(Req, Config)).
221+
222+
223+
wildcard_subdomain_with_port(_Config) ->
224+
Allowed = [<<"*.example.com">>],
225+
Config = config(#{allowed_origins => Allowed}),
226+
Req = fake_req(<<"https://app.example.com:8443">>),
227+
Headers = bondy_http_cors:headers(Req, Config),
228+
?assertEqual(
229+
<<"https://app.example.com:8443">>,
230+
maps:get(<<"access-control-allow-origin">>, Headers)
231+
).
232+
233+
234+
wildcard_subdomain_mixed_with_exact(_Config) ->
235+
%% Mix of wildcard subdomain and exact origin
236+
Allowed = [<<"*.example.com">>, <<"https://other.com">>],
237+
Config = config(#{allowed_origins => Allowed}),
238+
%% Subdomain match
239+
Req1 = fake_req(<<"https://app.example.com">>),
240+
Headers1 = bondy_http_cors:headers(Req1, Config),
241+
?assertEqual(
242+
<<"https://app.example.com">>,
243+
maps:get(<<"access-control-allow-origin">>, Headers1)
244+
),
245+
%% Exact match
246+
Req2 = fake_req(<<"https://other.com">>),
247+
Headers2 = bondy_http_cors:headers(Req2, Config),
248+
?assertEqual(
249+
<<"https://other.com">>,
250+
maps:get(<<"access-control-allow-origin">>, Headers2)
251+
),
252+
%% No match
253+
Req3 = fake_req(<<"https://nope.com">>),
254+
?assertEqual(#{}, bondy_http_cors:headers(Req3, Config)).
255+
256+
257+
191258
%% =============================================================================
192259
%% HELPERS
193260
%% =============================================================================

doc/guides/configuration/http_security_headers.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,20 @@ Controls which origins are permitted to make cross-origin requests. Three modes
3737
api_gateway.http.cors.allowed_origins = *
3838
```
3939

40-
**Explicit allowlist** -- a comma-separated list of exact origins. Only requests whose `Origin` header matches one of these values will receive CORS headers. Requests from unlisted origins receive no `Access-Control-Allow-Origin` header at all, which causes the browser to block the response. When a match is found, `Access-Control-Allow-Credentials` is set to `true` and a `Vary: Origin` header is added:
40+
**Explicit allowlist** -- a comma-separated list of origins. Only requests whose `Origin` header matches one of these values will receive CORS headers. Requests from unlisted origins receive no `Access-Control-Allow-Origin` header at all, which causes the browser to block the response. When a match is found, `Access-Control-Allow-Credentials` is set to `true` and a `Vary: Origin` header is added.
41+
42+
Entries can be exact origins or wildcard subdomain patterns using the `*.` prefix:
4143

4244
```
4345
api_gateway.http.cors.allowed_origins = https://app.example.com, https://admin.example.com
4446
```
4547

48+
```
49+
api_gateway.http.cors.allowed_origins = *.example.com, https://other.com
50+
```
51+
52+
A wildcard pattern like `*.example.com` matches any subdomain (e.g. `https://app.example.com`, `https://staging.example.com`) but does **not** match the bare domain `https://example.com` itself. The exact requesting origin is always reflected back in the `Access-Control-Allow-Origin` header -- the `*.` is purely a server-side config convenience.
53+
4654
**Auto** -- derives the allowed origin from the incoming request's own scheme, host, and port (`Scheme://Host[:Port]`). This is effectively "same-origin only" and is useful when the frontend application is served from the same domain as the API. Default ports (80 for HTTP, 443 for HTTPS) are omitted:
4755

4856
```

0 commit comments

Comments
 (0)