Skip to content

Commit 0f75f6b

Browse files
Fix wildcard hostname matching to reject hostnames without dots (#14787)
* Fix wildcard hostname matching to reject hostnames without dots * Update .changeset/fix-wildcard-hostname-matching.md Co-authored-by: Florian Lefebvre <[email protected]> --------- Co-authored-by: Florian Lefebvre <[email protected]>
1 parent 504958f commit 0f75f6b

File tree

4 files changed

+43
-5
lines changed

4 files changed

+43
-5
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"astro": patch
3+
"@astrojs/internal-helpers": patch
4+
---
5+
6+
Fixes wildcard hostname pattern matching to correctly reject hostnames without dots
7+
8+
Previously, hostnames like `localhost` or other single-part names would incorrectly match patterns like `*.example.com`. The wildcard matching logic has been corrected to ensure that only valid subdomains matching the pattern are accepted.

packages/astro/test/units/app/node.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ describe('NodeApp', () => {
119119
assert.equal(result.url, 'https://legitimate.example.com/');
120120
});
121121

122+
it('rejects x-forwarded-host with no dots (e.g. localhost) against single wildcard pattern', () => {
123+
const result = NodeApp.createRequest(
124+
{
125+
...mockNodeRequest,
126+
headers: {
127+
host: 'example.com',
128+
'x-forwarded-host': 'localhost',
129+
},
130+
},
131+
{ allowedDomains: [{ hostname: '*.victim.com' }] },
132+
);
133+
// localhost should not match *.victim.com, fallback to host header
134+
assert.equal(result.url, 'https://example.com/');
135+
});
136+
122137
it('rejects x-forwarded-host with path separator (path injection attempt)', () => {
123138
const result = NodeApp.createRequest(
124139
{

packages/astro/test/units/remote-pattern.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ describe('remote-pattern', () => {
5858
assert.equal(matchHostname(url3, '**.astro.build', true), false);
5959
});
6060

61+
it('rejects hostname without dots when using single wildcard (*.domain.com)', async () => {
62+
// hostnames without dots (like localhost) should not match *.astro.build
63+
const localhostUrl = new URL('http://localhost/');
64+
assert.equal(matchHostname(localhostUrl, '*.astro.build', true), false);
65+
66+
const bareHostnameUrl = new URL('http://example/');
67+
assert.equal(matchHostname(bareHostnameUrl, '*.victim.com', true), false);
68+
69+
const internalUrl = new URL('http://internal/');
70+
assert.equal(matchHostname(internalUrl, '*.astro.build', true), false);
71+
});
72+
6173
it('matches pathname (no wildcards)', async () => {
6274
// undefined
6375
assert.equal(matchPathname(url1), true);

packages/internal-helpers/src/remote.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,14 @@ export function matchHostname(url: URL, hostname?: string, allowWildcard = false
6161
return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
6262
} else if (hostname.startsWith('*.')) {
6363
const slicedHostname = hostname.slice(1); // * length
64-
const additionalSubdomains = url.hostname
65-
.replace(slicedHostname, '')
66-
.split('.')
67-
.filter(Boolean);
68-
return additionalSubdomains.length === 1;
64+
// Check if url hostname ends with the base domain
65+
if (!url.hostname.endsWith(slicedHostname)) {
66+
return false;
67+
}
68+
// Extract the subdomain part (before the base domain, excluding the dot)
69+
const subdomainWithDot = url.hostname.slice(0, -(slicedHostname.length - 1));
70+
// Should be exactly one subdomain part followed by a dot
71+
return subdomainWithDot.endsWith('.') && !subdomainWithDot.slice(0, -1).includes('.');
6972
}
7073

7174
return false;

0 commit comments

Comments
 (0)