Unauthorized third-party images in Astro’s _image endpoint
Description
Astro is a web framework for content-driven websites. In versions of astro before 5.13.2 and 4.16.18, the image optimization endpoint in projects deployed with on-demand rendering allows images from unauthorized third-party domains to be served. On-demand rendered sites built with Astro include an /_image endpoint which returns optimized versions of images. A bug in impacted versions of astro allows an attacker to bypass the third-party domain restrictions by using a protocol-relative URL as the image source, e.g. /_image?href=//example.com/image.png. This vulnerability is fixed in 5.13.2 and 4.16.18.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
astronpm | >= 5.0.0-alpha.0, < 5.13.2 | 5.13.2 |
@astrojs/nodenpm | < 9.1.1 | 9.1.1 |
astronpm | < 4.16.19 | 4.16.19 |
Affected products
1Patches
14d16de7f95dbMerge commit from fork
6 files changed · +97 −2
.changeset/fresh-gifts-take.md+6 −0 added@@ -0,0 +1,6 @@ +--- +'@astrojs/internal-helpers': patch +'astro': patch +--- + +Improves the detection of remote paths in the `_image` endpoint. Now `href` parameters that start with `//` are considered remote paths.
packages/astro/test/core-image.test.js+28 −0 modified@@ -896,6 +896,24 @@ describe('astro:image', () => { const img = await app.render(new Request(`https://example.com${src}`)); assert.equal(img.status, 200); }); + + it('returns 403 when loading a relative pattern iamge', async () => { + const fixtureWithBase = await loadFixture({ + root: './fixtures/core-image-ssr/', + output: 'server', + outDir: './dist/server-base-path', + adapter: testAdapter(), + }); + await fixtureWithBase.build(); + const app = await fixtureWithBase.loadTestAdapterApp(); + let request = new Request('http://example.com/'); + let response = await app.render(request); + // making sure that the app works + assert.equal(response.status, 200); + request = new Request('http://example.com/_image/?href=//secure0x.netlify.app/secure0x.svg&f=svg'); + response = await app.render(request); + assert.equal(response.status, 403); + }); }); describe('build ssg', () => { @@ -1396,6 +1414,16 @@ describe('astro:image', () => { assert.equal(src.startsWith('/_image?'), true); }); + it("returns 403 for /_image when requesting a relative pattern image and the parameters aren't encoded", async () => { + fixture = await loadFixture({ + root: './fixtures/core-image/', + }); + devServer = await fixture.startDevServer(); + // we don't use `URLSearchParams` because the initial // will get encoded + const response = await fixture.fetch('/_image?' + "href=//secure0x.netlify.app/secure0x.svg&f=svg"); + assert.equal(response.status, 403); + }) + afterEach(async () => { await devServer.stop(); });
packages/astro/test/fixtures/core-image/src/pages/empty.astro+16 −0 added@@ -0,0 +1,16 @@ +--- + +--- + +<html lang="en"> +<head> + <meta charset="utf-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width" /> + <meta name="generator" content={Astro.generator} /> + <title>Astro</title> +</head> +<body> +<h1>Astro</h1> +</body> +</html>
packages/internal-helpers/package.json+2 −1 modified@@ -36,7 +36,8 @@ "prepublish": "pnpm build", "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "dev": "astro-scripts dev \"src/**/*.ts\"" + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "astro-scripts test \"test/**/*.test.js\"" }, "devDependencies": { "astro-scripts": "workspace:*"
packages/internal-helpers/src/path.ts+21 −1 modified@@ -101,8 +101,28 @@ export function removeQueryString(path: string) { return index > 0 ? path.substring(0, index) : path; } +/** + * Regex that matches the following URLs like: + * - http://example.com + * - https://example.com + * - ftp://example.com + * - ws://example.com + * - //example.com (protocol-relative URLs) + */ +const URL_PROTOCOL_REGEX = /^(?:(?:http|ftp|https|ws):?\/\/|\/\/)/; + +/** + * Checks whether the path is considered a remote path. Paths need to start with: + * - `http://` + * - `https://` + * - `ftp://` + * - `ws://` + * - `//` (protocol-relative URLs) + * - `data:` (base64 images) + * @param src + */ export function isRemotePath(src: string) { - return /^(?:http|ftp|https|ws):?\/\//.test(src) || src.startsWith('data:'); + return URL_PROTOCOL_REGEX.test(src) || src.startsWith('data:'); } export function slash(path: string) {
packages/internal-helpers/test/path.test.js+24 −0 added@@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isRemotePath } from '../dist/path.js'; + +describe('isRemotePath', () => { + it("should return true if the path is remote", () => { + assert.equal(isRemotePath('https://example.com/foo/bar.js'), true, "should be a remote path") + assert.equal(isRemotePath('http://example.com/foo/bar.js'), true, "should be a remote path") + assert.equal(isRemotePath('//example.com/foo/bar.js'), true, "should be a remote path") + assert.equal(isRemotePath('ws://example.com/foo/bar.js'), true, "should be a remote path") + assert.equal(isRemotePath('ftp://example.com/foo/bar.js'), true, "should be a remote path") + assert.equal(isRemotePath('data:someCode'), true, "should be a remote path") + // false + assert.equal(isRemotePath('/local/path/file.js'), false, "should not be a remote path") + assert.equal(isRemotePath('relative/path/file.js'), false, "should not be a remote path") + assert.equal(isRemotePath('./relative/path/file.js'), false, "should not be a remote path") + assert.equal(isRemotePath('../relative/path/file.js'), false, "should not be a remote path") + assert.equal(isRemotePath('C:\\windows\\path\\file.js'), false, "should not be a remote path") + assert.equal(isRemotePath('file://example.com/foo/bar.js'), false, "should not be a remote path") + assert.equal(isRemotePath('sftp://example.com/foo/bar.js'), false, "should not be a remote path") + assert.equal(isRemotePath('wss://example.com/foo/bar.js'), false, "should not be a remote path") + assert.equal(isRemotePath('mailto:example@example.com'), false, "should not be a remote path") + }) +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-xf8x-j4p2-f749ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-55303ghsaADVISORY
- github.com/withastro/astro/commit/4d16de7f95db5d1ec1ce88610d2a95e606e83820ghsax_refsource_MISCWEB
- github.com/withastro/astro/security/advisories/GHSA-xf8x-j4p2-f749ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.