Summary
Requesting a static js/css resources from _astro path with an incorrect/malformed if-match header returns a 500 error with a one year cache lifetime instead of 412 in some cases. This has the effect that all subsequent requests to that file, regardless of if-match header will be served a 5xx error instead of the file until the cache expires.
Sending an incorrect or malformed if-match header should always returns a 412 error without any cache headers, which is not happening now.
Affected Versions
We are running astro@5.14.1 & @astrojs/node@9.4.4
PoC
We have prepared a testing domain for you to freely work on: https://astro-cve.dev.tagesanzeiger.ch/
run curl -s -o /dev/null -D - https://astro-cve.dev.tagesanzeiger.ch/_astro/_slug_.UTbyeVfw.css -H "if-match: xxx" on your console.
If you do not get a 5xx error. Look into your web inspector on https://astro-cve.dev.tagesanzeiger.ch/ for resources and select another css/js file to request until you get 5xx. generally we always get a 5xx. Do not forget that all static files are immutable so you must purge/disable your cache
You should get a response from cloudfront similar to this
HTTP/2 500
content-type: text/html
content-length: 166541
date: Thu, 09 Apr 2026 12:53:08 GMT
last-modified: Wed, 21 Jan 2026 13:40:08 GMT
etag: "a68349e96c2faf8861c330aeb548441a"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Error from cloudfront
via: 1.1 3591be88662e5675a9dc1cc4e0a9c392.cloudfront.net (CloudFront)
x-amz-cf-pop: ZRH55-P2
x-amz-cf-id: Rg--RIYCKcA55GZqZXdvu-VTvpxBFFVzV4LBIcKq5pB_hktcrhYbKg==
Off course what you are seeing above is not the real server output but the triggered AWS error response when our pods send a 5xx. Here is the output of such a curl command directly against a pod from kubernetes:
❯ curl -s -o /dev/null -D - -H "Host: tagesanzeiger.ch" 127.0.0.1:3333/_astro/InstallPrompt.astro_astro_type_script_index_0_lang.C0M4llHG.js -H "if-match: xxx"
HTTP/1.1 500 Internal Server Error
Cache-Control: public, max-age=31536000, immutable
Accept-Ranges: bytes
Last-Modified: Tue, 07 Apr 2026 07:08:03 GMT
ETag: W/"560-19d66c50c38"
Content-Type: text/javascript; charset=utf-8
Date: Tue, 07 Apr 2026 08:23:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
You can clearly see here that it's actually the pod returning a 5xx error instead of 412. Not only that, it's including a Cache-Control: public, max-age=31536000, immutable header!
because we have now setup if-match cache keys, this exploit does not work on our application anymore. Before this change, our CDN Point of Presence would get cache poisoned and any client visiting our webpages without cached files through the same PoP would have broken pages. We have reproduced this by creating testing urls and only visiting them in a browser after running the exploit. We can confirm that those resources which were exploited return 5xx errors instead of the content of the css/js files and thus breaking the app.
Details
We ran our findings through a LLM and it pointed us to the file serve-static.s
Lines 129-153
let forwardError = false;
stream.on('error', (err) => {
if (forwardError) {
console.error(err.toString());
res.writeHead(500);
res.end('Internal server error');
return;
}
// File not found, forward to the SSR handler
ssr();
});
stream.on('headers', (_res: ServerResponse) => {
// assets in dist/_astro are hashed and should get the immutable header
if (normalizedPathname.startsWith(`/${app.manifest.assetsDir}/`)) {
// This is the "far future" cache header, used for static files whose name includes their digest hash.
// 1 year (31,536,000 seconds) is convention.
// Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable
_res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
});
stream.on('file', () => {
forwardError = true;
});
stream.pipe(res);
LLM output:
send handles conditional request headers like If-Match internally. When a file is found but the precondition fails (ETag mismatch), send:
1. Emits file (the file exists) → forwardError = true
2. Emits headers → Cache-Control: public, max-age=31536000, immutable is set on res
3. Emits error with a PreconditionFailedError (status 412)
But the error handler doesn't look at the error's status code at all:
stream.on('error', (err) => {
if (forwardError) {
console.error(err.toString());
res.writeHead(500); // ← always 500, regardless of what err actually is
res.end('Internal server error');
return;
}
ssr();
});
Because Cache-Control was already set in the headers event, the response goes out as:
HTTP/1.1 500 Internal Server Error
Cache-Control: public, max-age=31536000, immutable
We have no idea if this is any help at all but hopefully it points you in the right direction.
Impact
Cache Poisoning - An attacker can force the edge servers to cache an error page instead of the actual content, rendering one or more assets unavailable.
Summary
Requesting a static js/css resources from
_astropath with an incorrect/malformedif-matchheader returns a500error with a one year cache lifetime instead of412in some cases. This has the effect that all subsequent requests to that file, regardless of if-match header will be served a 5xx error instead of the file until the cache expires.Sending an incorrect or malformed
if-matchheader should always returns a412error without any cache headers, which is not happening now.Affected Versions
We are running
astro@5.14.1&@astrojs/node@9.4.4PoC
We have prepared a testing domain for you to freely work on: https://astro-cve.dev.tagesanzeiger.ch/
run
curl -s -o /dev/null -D - https://astro-cve.dev.tagesanzeiger.ch/_astro/_slug_.UTbyeVfw.css -H "if-match: xxx"on your console.If you do not get a 5xx error. Look into your web inspector on https://astro-cve.dev.tagesanzeiger.ch/ for resources and select another css/js file to request until you get 5xx. generally we always get a 5xx. Do not forget that all static files are immutable so you must purge/disable your cache
You should get a response from cloudfront similar to this
Off course what you are seeing above is not the real server output but the triggered AWS error response when our pods send a 5xx. Here is the output of such a curl command directly against a pod from kubernetes:
You can clearly see here that it's actually the pod returning a
5xxerror instead of412. Not only that, it's including aCache-Control: public, max-age=31536000, immutableheader!because we have now setup
if-matchcache keys, this exploit does not work on our application anymore. Before this change, ourCDN Point of Presencewould get cache poisoned and any client visiting our webpages without cached files through the samePoPwould have broken pages. We have reproduced this by creating testing urls and only visiting them in a browser after running the exploit. We can confirm that those resources which were exploited return5xxerrors instead of the content of thecss/jsfiles and thus breaking the app.Details
We ran our findings through a LLM and it pointed us to the file serve-static.s
LLM output:
We have no idea if this is any help at all but hopefully it points you in the right direction.
Impact
Cache Poisoning - An attacker can force the edge servers to cache an error page instead of the actual content, rendering one or more assets unavailable.