GitHub Security Advisory: GHSA-g735-7g2w-hh3f
Package: astro (NPM)
Severity: LOW (CVSS 5.3)
CWE: CWE-20: Improper Input Validation, CWE-183: Permissive List of Allowed Inputs
Vulnerable versions: >= 2.10.10, < 5.18.1
Patched in: 5.18.1

Summary: Astro: Remote allowlist bypass via unanchored matchPathname wildcard

Details:
## Summary
This issue concerns Astro's `remotePatterns` path enforcement for remote URLs used by server-side fetchers such as the image optimization endpoint. The path matching logic for `/*` wildcards is unanchored, so a pathname that contains the allowed prefix later in the path can still match. As a result, an attacker can fetch paths outside the intended allowlisted prefix on an otherwise allowed host. In our PoC, both the allowed path and a bypass path returned 200 with the same SVG payload, confirming the bypass.

## Impact
Attackers can fetch unintended remote resources on an allowlisted host via the image endpoint, expanding SSRF/data exposure beyond the configured path prefix.

## Description
Taint flow: request -> `transform.src` -> `isRemoteAllowed()` -> `matchPattern()` -> `matchPathname()`

User-controlled `href` is parsed into `transform.src` and validated via `isRemoteAllowed()`:

Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/astro/src/assets/endpoint/generic.ts#L43-L56

```ts
const url = new URL(request.url);
const transform = await imageService.parseURL(url, imageConfig);

const isRemoteImage = isRemotePath(transform.src);

if (isRemoteImage && isRemoteAllowed(transform.src, imageConfig) === false) {
  return new Response('Forbidden', { status: 403 });
}
```

`isRemoteAllowed()` checks each `remotePattern` via `matchPattern()`:

Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/internal-helpers/src/remote.ts#L15-L21

```ts
export function matchPattern(url: URL, remotePattern: RemotePattern): boolean {
  return (
    matchProtocol(url, remotePattern.protocol) &&
    matchHostname(url, remotePattern.hostname, true) &&
    matchPort(url, remotePattern.port) &&
    matchPathname(url, remotePattern.pathname, true)
  );
}
```

The vulnerable logic in `matchPathname()` uses `replace()` without anchoring the prefix for `/*` patterns:

Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/internal-helpers/src/remote.ts#L85-L99

```ts
} else if (pathname.endsWith('/*')) {
  const slicedPathname = pathname.slice(0, -1); // * length
  const additionalPathChunks = url.pathname
    .replace(slicedPathname, '')
    .split('/')
    .filter(Boolean);
  return additionalPathChunks.length === 1;
}
```

**Vulnerable code flow:**
1. `isRemoteAllowed()` evaluates `remotePatterns` for a requested URL.
2. `matchPathname()` handles `pathname: "/img/*"` using `.replace()` on the URL path.
3. A path such as `/evil/img/secret` incorrectly matches because `/img/` is removed even when it's not at the start.
4. The image endpoint fetches and returns the remote resource.

## PoC

The PoC starts a local attacker server and configures remotePatterns to allow only `/img/*`. It then requests the image endpoint with two URLs: an allowed path and a bypass path with `/img/` in the middle. Both requests returned the SVG payload