GHSA-C57F-MM3J-27Q9

Vulnerability from github – Published: 2026-04-23 14:36 – Updated: 2026-04-23 14:36
VLAI?
Summary
Astro: Cache Poisoning due to incorrect error handling when if-match header is malformed
Details

Summary

Requesting a static JS/CSS resource from the _astro path with an incorrect or malformed if-match header returns a 500 error with a one-year cache lifetime instead of 412 in some cases. As a result, all subsequent requests to that file — regardless of the 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 return a 412 error without any cache headers, which is not the current behavior.

Affected Versions

  • astro@5.14.1
  • @astrojs/node@9.4.4

Proof of Concept

Run the following command:

curl -s -o /dev/null -D - <host location>/_astro/_slug_.UTbyeVfw.css -H "if-match: xxx"

If a 5xx error is not returned, inspect the resources via the browser's web inspector and select another CSS/JS file to request until a 5xx error is returned. The behavior generally defaults to a 5xx response. Note that all static files are immutable, so the cache must be purged or disabled to reproduce reliably.

A response similar to the following is expected from CloudFront:

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==

The above is not the real server output but the AWS error response triggered when the pods return a 5xx. Below is the output of the same curl command issued directly against a pod in 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

This demonstrates that the pod itself returns a 5xx error instead of 412. In addition, the response includes a Cache-Control: public, max-age=31536000, immutable header.

Because the testing setup configures if-match as part of the cache key, the exploit no longer affects the production application. Prior to that change, the CDN Point of Presence would become cache-poisoned, and any client visiting the affected pages without cached files through the same PoP would receive broken pages. This was reproduced by creating test URLs and visiting them in a browser only after triggering the exploit. The exploited resources returned 5xx errors instead of the original CSS/JS content, breaking the application.

Details

The findings were analyzed with an LLM, which identified the following file as the likely source: serve-static.ts

// 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 analysis:

send handles conditional request headers such as 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 headersCache-Control: public, max-age=31536000, immutable is set on res
  3. Emits error with a PreconditionFailedError (status 412)

However, the error handler does not inspect the error's status code:

js stream.on('error', (err) => { if (forwardError) { console.error(err.toString()); res.writeHead(500); // ← always 500, regardless of the actual error res.end('Internal server error'); return; } ssr(); });

Because Cache-Control was already set during the headers event, the response is sent as:

HTTP/1.1 500 Internal Server Error Cache-Control: public, max-age=31536000, immutable

Impact

Cache Poisoning — An attacker can force edge servers to cache an error page instead of the actual content, rendering one or more assets unavailable to legitimate users until the cache expires.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@astrojs/node"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "10.0.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41322"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-525"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-23T14:36:03Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\nRequesting a static JS/CSS resource from the `_astro` path with an incorrect or malformed `if-match` header returns a `500` error with a one-year cache lifetime instead of `412` in some cases. As a result, all subsequent requests to that file \u2014 regardless of the `if-match` header \u2014 will be served a 5xx error instead of the file until the cache expires.\n\nSending an incorrect or malformed `if-match` header should always return a `412` error without any cache headers, which is not the current behavior.\n\n### Affected Versions\n- `astro@5.14.1`\n- `@astrojs/node@9.4.4`\n\n### Proof of Concept\n\nRun the following command:\n\n```\ncurl -s -o /dev/null -D - \u003chost location\u003e/_astro/_slug_.UTbyeVfw.css -H \"if-match: xxx\"\n```\n\nIf a 5xx error is not returned, inspect the resources via the browser\u0027s web inspector and select another CSS/JS file to request until a 5xx error is returned. The behavior generally defaults to a 5xx response. Note that all static files are immutable, so the cache must be purged or disabled to reproduce reliably.\n\nA response similar to the following is expected from CloudFront:\n\n```\nHTTP/2 500 \ncontent-type: text/html\ncontent-length: 166541\ndate: Thu, 09 Apr 2026 12:53:08 GMT\nlast-modified: Wed, 21 Jan 2026 13:40:08 GMT\netag: \"a68349e96c2faf8861c330aeb548441a\"\nx-amz-server-side-encryption: AES256\naccept-ranges: bytes\nserver: AmazonS3\nx-cache: Error from cloudfront\nvia: 1.1 3591be88662e5675a9dc1cc4e0a9c392.cloudfront.net (CloudFront)\nx-amz-cf-pop: ZRH55-P2\nx-amz-cf-id: Rg--RIYCKcA55GZqZXdvu-VTvpxBFFVzV4LBIcKq5pB_hktcrhYbKg==\n```\n\nThe above is not the real server output but the AWS error response triggered when the pods return a 5xx. Below is the output of the same `curl` command issued directly against a pod in Kubernetes:\n\n```\n\u276f 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\"\n\nHTTP/1.1 500 Internal Server Error\nCache-Control: public, max-age=31536000, immutable\nAccept-Ranges: bytes\nLast-Modified: Tue, 07 Apr 2026 07:08:03 GMT\nETag: W/\"560-19d66c50c38\"\nContent-Type: text/javascript; charset=utf-8\nDate: Tue, 07 Apr 2026 08:23:54 GMT\nConnection: keep-alive\nKeep-Alive: timeout=5\nTransfer-Encoding: chunked\n```\n\nThis demonstrates that the pod itself returns a `5xx` error instead of `412`. In addition, the response includes a `Cache-Control: public, max-age=31536000, immutable` header.\n\nBecause the testing setup configures `if-match` as part of the cache key, the exploit no longer affects the production application. Prior to that change, the CDN Point of Presence would become cache-poisoned, and any client visiting the affected pages without cached files through the same PoP would receive broken pages. This was reproduced by creating test URLs and visiting them in a browser only after triggering the exploit. The exploited resources returned `5xx` errors instead of the original CSS/JS content, breaking the application.\n\n### Details\nThe findings were analyzed with an LLM, which identified the following file as the likely source: [serve-static.ts](https://github.com/withastro/astro/blob/main/packages/integrations/node/src/serve-static.ts)\n\n```js\n// Lines 129-153\n\nlet forwardError = false;\n\nstream.on(\u0027error\u0027, (err) =\u003e {\n    if (forwardError) {\n        console.error(err.toString());\n        res.writeHead(500);\n        res.end(\u0027Internal server error\u0027);\n        return;\n    }\n    // File not found, forward to the SSR handler\n    ssr();\n});\nstream.on(\u0027headers\u0027, (_res: ServerResponse) =\u003e {\n    // assets in dist/_astro are hashed and should get the immutable header\n    if (normalizedPathname.startsWith(`/${app.manifest.assetsDir}/`)) {\n        // This is the \"far future\" cache header, used for static files whose name includes their digest hash.\n        // 1 year (31,536,000 seconds) is convention.\n        // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable\n        _res.setHeader(\u0027Cache-Control\u0027, \u0027public, max-age=31536000, immutable\u0027);\n    }\n});\nstream.on(\u0027file\u0027, () =\u003e {\n    forwardError = true;\n});\nstream.pipe(res);\n```\n\nLLM analysis:\n\n\u003e `send` handles conditional request headers such as `If-Match` internally. When a file is found but the precondition fails (ETag mismatch), `send`:\n\u003e\n\u003e 1. Emits `file` (the file exists) \u2192 `forwardError = true`\n\u003e 2. Emits `headers` \u2192 `Cache-Control: public, max-age=31536000, immutable` is set on `res`\n\u003e 3. Emits `error` with a `PreconditionFailedError` (status 412)\n\u003e\n\u003e However, the error handler does not inspect the error\u0027s status code:\n\u003e\n\u003e ```js\n\u003e stream.on(\u0027error\u0027, (err) =\u003e {\n\u003e     if (forwardError) {\n\u003e         console.error(err.toString());\n\u003e         res.writeHead(500);   // \u2190 always 500, regardless of the actual error\n\u003e         res.end(\u0027Internal server error\u0027);\n\u003e         return;\n\u003e     }\n\u003e     ssr();\n\u003e });\n\u003e ```\n\u003e\n\u003e Because `Cache-Control` was already set during the `headers` event, the response is sent as:\n\u003e\n\u003e ```\n\u003e HTTP/1.1 500 Internal Server Error\n\u003e Cache-Control: public, max-age=31536000, immutable\n\u003e ```\n\n\n### Impact\n**Cache Poisoning** \u2014 An attacker can force edge servers to cache an error page instead of the actual content, rendering one or more assets unavailable to legitimate users until the cache expires.",
  "id": "GHSA-c57f-mm3j-27q9",
  "modified": "2026-04-23T14:36:03Z",
  "published": "2026-04-23T14:36:03Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/withastro/astro/security/advisories/GHSA-c57f-mm3j-27q9"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/withastro/astro"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Astro: Cache Poisoning due to incorrect error handling when if-match header is malformed "
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…