Skip to content

fix(media-server): check every remote URL against a private-address policy - #3000

Open
07rjain wants to merge 1 commit into
CapSoftware:mainfrom
07rjain:split/media-url-policy
Open

07rjain wants to merge 1 commit into
CapSoftware:mainfrom
07rjain:split/media-url-policy

Conversation

@07rjain

@07rjain 07rjain commented Oct 23, 2026 •

Copy link
Copy Markdown

Split out of #2580 (media-server URL policy and DASH handling).

Problem. The media server fetched whatever URL a request named. Redirects, HLS/DASH playlist entries and webhook URLs could point at loopback, private networks or cloud metadata (169.254.169.254), and ffmpeg followed playlist entries on its own.

What changes

  • url-policy.ts: only http/https. Hosts that resolve to loopback, private, link-local, CGNAT or IPv6 ULA addresses are rejected unless trusted. Trusted means S3_INTERNAL_ENDPOINT, S3_PUBLIC_ENDPOINT, CAP_AWS_ENDPOINT, MEDIA_SERVER_WEBHOOK_URL, WEB_URL, or MEDIA_SERVER_ALLOWED_HOSTS, which becomes a strict allow-list when set. MEDIA_SERVER_ALLOW_PRIVATE_URLS=true is for local dev and tests.
  • fetchAllowed checks every redirect hop. It's used for inputs, thumbnails, probes (inside the probe limit and timeout), recording verification (which decodes from a checked local file), audio, audio-level webhooks, job webhooks and the opt-in parallel ranged downloads.
  • HLS and DASH manifests are downloaded and rewritten to local files before ffmpeg runs with -protocol_whitelist file,pipe.
    • In DASH, MPD@Location is dropped and no longer changes the media base.
    • Sibling <BaseURL>s are alternatives, so each resolves against its parent. An alternative that is missing or blocked is dropped instead of failing the job.
    • Directory BaseURLs are never downloaded.
  • Blocked URLs return 400 URL_NOT_ALLOWED from the audio and video routes.
  • A failed remote audio download releases its operation right away.
  • The webhook secret is compared in constant time.
  • Compose files give the media server S3_INTERNAL_ENDPOINT/S3_PUBLIC_ENDPOINT/MEDIA_SERVER_WEBHOOK_URL so bundled MinIO stays trusted. packages/local-docker sets MEDIA_SERVER_ALLOW_PRIVATE_URLS=true.
  • Docs: "Media Server URL Policy" section.

Upgrade note. Self-hosts whose storage is on a private network must set S3_INTERNAL_ENDPOINT on the media server or list the host in MEDIA_SERVER_ALLOWED_HOSTS. Otherwise processing fails with URL_NOT_ALLOWED. The bundled compose files already do this.

Tests. url-policy.test.ts, redirect-follow.test.ts (probe, audio, thumbnail, verification and parallel download don't follow a redirect to a private address), and DASH tests for Location in another directory, sibling primary/backup BaseURLs and a missing alternative. Route tests now serve fixtures over HTTP because file: is rejected.

RetriggerView in GreptileConfidence Score: 0/5

Do not merge until connections use checked addresses, callbacks stop forwarding secrets across redirects, and the HLS and DASH compatibility breaks are fixed.

Findings

  1. P1 Security DNS changes bypass the check ▶
  2. P1 Security Redirects receive the callback secret ▶
  3. P1 Encrypted HLS audio fails ▶
  4. P1 DASH fallback rejects valid templates ▶
  5. P1 Valid XML references stay remote ▶
  6. P2 HLS downloads duplicate whole files ▶
  7. P2 Redirect tests miss the redirect ▶
Fix with agent prompt
### Issue 1
apps/media-server/src/lib/url-policy.ts:285-286
`fetchAllowed` checks DNS, then gives the original hostname to `fetcher`, which resolves it again. A hostname in an attacker-controlled playlist can return a public address for the check and a private address for the connection. This leaves the private-address fix bypassable.

Bind each connection to an address that passed the check, including in `resolveAllowedUrl`.

**How this was verified:** Playlist resources reach `fetchAllowed`, but the addresses checked by `assertUrlAllowed` are never passed to the network connection.

### Issue 2
apps/media-server/src/lib/audio-levels.ts:78-85
The audio-level callback previously refused redirects. It now follows them through `fetchAllowed`, which sends the same `x-media-server-secret` and JSON body to every target. With the default policy, a redirect to another public host passes, exposing the callback secret and recording details to that host.

Keep redirect refusal for this callback, and make `fetchAllowed` respect `redirect: "error"`.

**How this was verified:** The callback includes the secret header, and the redirect loop reuses those headers and the body without checking whether the origin changed.

### Issue 3
apps/media-server/src/lib/media-audio.ts:123-124
`getAudioExtractArgs` now allows only `file,pipe`. AES-128 HLS still needs FFmpeg's `crypto` protocol after the key and segments have been downloaded locally, so encrypted HLS audio extraction now fails. Include `crypto` here, as `getAudioProbeArgs` and the video download command already do.

### Issue 4
apps/media-server/src/lib/media-video.ts:458-459
The generic DASH fallback now rejects every URL containing `$`, but the HLS converter does not cover all valid template shapes. For example, it does not read a `SegmentTemplate` inherited from `Period`. That manifest falls back here and throws on `$Number$`.

Previously the generic MPD kept the template for FFmpeg to expand. Expand these templates into checked local resources before rejecting the fallback.

### Issue 5
apps/media-server/src/lib/media-video.ts:471-472
`rewriteDashAttributes` only matches double-quoted attributes with no spaces around `=`. Valid XML such as `<SegmentURL media='seg-1.m4s'/>` is left unchanged, so its resource is never downloaded.

This worked before because FFmpeg could fetch the remote resource. With directory `BaseURL` elements removed and network protocols disabled, FFmpeg now looks for a nonexistent local file or rejects the remote URL. Parse these attributes with the same quote and whitespace support as `parseXmlAttributes`.

### Issue 6
apps/media-server/src/lib/media-video.ts:394-399
Each HLS resource occurrence now downloads the entire URL into a fresh file. A byte-range playlist can name one large media file hundreds of times, so this downloads and stores hundreds of full copies instead of the requested ranges. Cache downloads by resolved URL, or download the named ranges and rewrite their offsets.

### Issue 7
apps/media-server/src/__tests__/lib/redirect-follow.test.ts:51-56
These tests start with a private loopback URL while private URLs are disabled. The policy rejects the first URL, so the redirect response is never reached. All five tests would still pass if the corresponding download path followed unchecked redirects after allowing the initial URL.

Make the first hop allowed, redirect it to a different blocked destination, and assert that the first hop was requested.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR adds a shared URL policy across media downloads, redirects, and callbacks.

  • Media-server requests now check each URL before connecting.
  • Streaming playlists now use checked local copies during processing.
  • Remote media is saved locally before audio, probe, and verification work.
  • Media-server secret checks no longer reveal partial matches.

Reviews (1) · Last reviewed commit: "fix(media-server): check every remote UR..." · Reviewed by Greptile

…olicy and localize DASH/HLS inputs

The media server fetched whatever URL a request named, and ffmpeg followed playlist entries and redirects on its own. Every remote read (inputs, HLS/DASH resources, thumbnails, probes, recording verification, audio, webhooks, parallel ranged downloads) now goes through fetchAllowed: only http(s), no loopback/private/link-local/metadata/CGNAT/ULA addresses unless the host is trusted or listed in MEDIA_SERVER_ALLOWED_HOSTS, and each redirect hop is checked again. HLS and DASH manifests are downloaded and rewritten to local files before ffmpeg runs with a file,pipe protocol whitelist. In DASH, MPD@Location no longer changes the media base and sibling BaseURLs resolve against their parent. The webhook secret is compared in constant time.
@vercel

vercel Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

@07rjain is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines +285 to +286
await assertUrlAllowed(current, policy, resolve);
const response = await fetcher(current, withManualRedirect(init));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security DNS changes bypass the check

fetchAllowed checks DNS, then gives the original hostname to fetcher, which resolves it again. A hostname in an attacker-controlled playlist can return a public address for the check and a private address for the connection. This leaves the private-address fix bypassable.

Bind each connection to an address that passed the check, including in resolveAllowedUrl.

How this was verified: Playlist resources reach fetchAllowed, but the addresses checked by assertUrlAllowed are never passed to the network connection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/url-policy.ts
Line: 285-286

Comment:
**DNS changes bypass the check**

`fetchAllowed` checks DNS, then gives the original hostname to `fetcher`, which resolves it again. A hostname in an attacker-controlled playlist can return a public address for the check and a private address for the connection. This leaves the private-address fix bypassable.

Bind each connection to an address that passed the check, including in `resolveAllowedUrl`.

**How this was verified:** Playlist resources reach `fetchAllowed`, but the addresses checked by `assertUrlAllowed` are never passed to the network connection.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +78 to 85
const response = await fetchAllowed(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-media-server-secret": webhookSecret,
},
body: JSON.stringify({ kind: "audio-levels", ...body }),
redirect: "error",
signal,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Redirects receive the callback secret

The audio-level callback previously refused redirects. It now follows them through fetchAllowed, which sends the same x-media-server-secret and JSON body to every target. With the default policy, a redirect to another public host passes, exposing the callback secret and recording details to that host.

Keep redirect refusal for this callback, and make fetchAllowed respect redirect: "error".

How this was verified: The callback includes the secret header, and the redirect loop reuses those headers and the body without checking whether the origin changed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/audio-levels.ts
Line: 78-85

Comment:
**Redirects receive the callback secret**

The audio-level callback previously refused redirects. It now follows them through `fetchAllowed`, which sends the same `x-media-server-secret` and JSON body to every target. With the default policy, a redirect to another public host passes, exposing the callback secret and recording details to that host.

Keep redirect refusal for this callback, and make `fetchAllowed` respect `redirect: "error"`.

**How this was verified:** The callback includes the secret header, and the redirect loop reuses those headers and the body without checking whether the origin changed.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +123 to +124
"-protocol_whitelist",
"file,pipe",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Encrypted HLS audio fails

getAudioExtractArgs now allows only file,pipe. AES-128 HLS still needs FFmpeg's crypto protocol after the key and segments have been downloaded locally, so encrypted HLS audio extraction now fails. Include crypto here, as getAudioProbeArgs and the video download command already do.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/media-audio.ts
Line: 123-124

Comment:
**Encrypted HLS audio fails**

`getAudioExtractArgs` now allows only `file,pipe`. AES-128 HLS still needs FFmpeg's `crypto` protocol after the key and segments have been downloaded locally, so encrypted HLS audio extraction now fails. Include `crypto` here, as `getAudioProbeArgs` and the video download command already do.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +458 to +459
if (resolved.includes("$")) {
throw new Error("DASH template URL cannot be fetched locally");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DASH fallback rejects valid templates

The generic DASH fallback now rejects every URL containing $, but the HLS converter does not cover all valid template shapes. For example, it does not read a SegmentTemplate inherited from Period. That manifest falls back here and throws on $Number$.

Previously the generic MPD kept the template for FFmpeg to expand. Expand these templates into checked local resources before rejecting the fallback.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/media-video.ts
Line: 458-459

Comment:
**DASH fallback rejects valid templates**

The generic DASH fallback now rejects every URL containing `$`, but the HLS converter does not cover all valid template shapes. For example, it does not read a `SegmentTemplate` inherited from `Period`. That manifest falls back here and throws on `$Number$`.

Previously the generic MPD kept the template for FFmpeg to expand. Expand these templates into checked local resources before rejecting the fallback.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +471 to +472
return attributes.replace(
/(initialization|media|sourceURL|xlink:href|href)="([^"]+)"/gi,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Valid XML references stay remote

rewriteDashAttributes only matches double-quoted attributes with no spaces around =. Valid XML such as <SegmentURL media='seg-1.m4s'/> is left unchanged, so its resource is never downloaded.

This worked before because FFmpeg could fetch the remote resource. With directory BaseURL elements removed and network protocols disabled, FFmpeg now looks for a nonexistent local file or rejects the remote URL. Parse these attributes with the same quote and whitespace support as parseXmlAttributes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/media-video.ts
Line: 471-472

Comment:
**Valid XML references stay remote**

`rewriteDashAttributes` only matches double-quoted attributes with no spaces around `=`. Valid XML such as `<SegmentURL media='seg-1.m4s'/>` is left unchanged, so its resource is never downloaded.

This worked before because FFmpeg could fetch the remote resource. With directory `BaseURL` elements removed and network protocols disabled, FFmpeg now looks for a nonexistent local file or rejects the remote URL. Parse these attributes with the same quote and whitespace support as `parseXmlAttributes`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +394 to +399
return await downloadAllowedResource(
resolved,
dirPath,
abortSignal,
limit,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 HLS downloads duplicate whole files

Each HLS resource occurrence now downloads the entire URL into a fresh file. A byte-range playlist can name one large media file hundreds of times, so this downloads and stores hundreds of full copies instead of the requested ranges. Cache downloads by resolved URL, or download the named ranges and rewrite their offsets.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/media-video.ts
Line: 394-399

Comment:
**HLS downloads duplicate whole files**

Each HLS resource occurrence now downloads the entire URL into a fresh file. A byte-range playlist can name one large media file hundreds of times, so this downloads and stores hundreds of full copies instead of the requested ranges. Cache downloads by resolved URL, or download the named ranges and rewrite their offsets.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +51 to +56
test("probe does not fetch the redirect target", async () => {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = "false";
const target = privateRedirectServer();
try {
await expect(probeVideo(target.url)).rejects.toThrow("private address");
expect(target.secretHits()).toBe(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redirect tests miss the redirect

These tests start with a private loopback URL while private URLs are disabled. The policy rejects the first URL, so the redirect response is never reached. All five tests would still pass if the corresponding download path followed unchecked redirects after allowing the initial URL.

Make the first hop allowed, redirect it to a different blocked destination, and assert that the first hop was requested.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/__tests__/lib/redirect-follow.test.ts
Line: 51-56

Comment:
**Redirect tests miss the redirect**

These tests start with a private loopback URL while private URLs are disabled. The policy rejects the first URL, so the redirect response is never reached. All five tests would still pass if the corresponding download path followed unchecked redirects after allowing the initial URL.

Make the first hop allowed, redirect it to a different blocked destination, and assert that the first hop was requested.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant