Skip to content

fix(self-host): harden the media server and shrink the embed - #2580

Open
07rjain wants to merge 13 commits into
CapSoftware:mainfrom
07rjain:core-improvements
Open

07rjain wants to merge 13 commits into
CapSoftware:mainfrom
07rjain:core-improvements

Conversation

@07rjain

@07rjain 07rjain commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

  • Self-host no longer ships public default secrets, never sends mail from auth@undefined, and checks MEDIA_SERVER_URL at startup without blocking boot. Coolify’s web healthcheck uses /api/status.
  • The media server blocks private, metadata, and non-http URLs. Trusted storage and webhook hosts match host and port, and each redirect hop is checked before fetch or ffmpeg sees it. The webhook secret is compared in constant time.
  • Shutdown drains video, probe, and audio work and keeps /health at HTTP 200 with status: "draining", so the healthcheck does not kill the drain. Compose media services wait 30s. Direct conversion concurrency follows CPU count (1 on small machines, up to 4).
  • The embed player is code-split. On a production build, /embed/[videoId] first-load JavaScript dropped from 3,258,857 bytes (28 chunks) to 1,347,848 bytes (19 chunks). Home and /embed stayed the same.
  • A v1 invite with email unset returns emailDelivery: "not_configured" instead of a 503 after the invite row is saved.

DNS is checked, then Bun fetch and ffmpeg resolve the name again. A host that changes address between those steps can still get through. Redirect targets are checked.

Test plan

  • bun test in apps/media-server for url-policy, health, video-capacity, and the video and audio routes
  • Vitest media-server-startup-check and agent-api-contract
  • Self-host: media server stop_grace_period is 30s, and cap-web’s healthcheck hits /api/status
  • Embed page still plays, and /embed/[videoId] first-load JS is smaller
  • An invite with Resend unset still creates the invite and reports not_configured
  • SIGTERM during a probe or audio job waits for it, and /health stays HTTP 200 while draining

Made with Cursor

RetriggerConfidence Score: 2/5

Fix the unchecked redirect paths and probe lifecycle gap before merging.

Findings

  1. P1 Security Redirects bypass URL checks ▶
  2. P1 Probes wait outside their limits ▶
  3. P2 Explicit senders stop working ▶
  4. P2 Local-file advice cannot work ▶
Fix with agent prompt
### Issue 1
apps/media-server/src/lib/media-video.ts:324-325
`materializeHlsPlaylist` checks a segment URL but leaves that remote URL in the local playlist. If a public segment redirects to `http://127.0.0.1:3000/`, ffmpeg follows it without another policy check. Audio extraction, webhook delivery, and storage uploads also still bypass redirect checks.

Route every remote request through the policy, including playlist resources and uploads. Checking a URL before handing it to ffmpeg is not enough.

**How this was verified:** Checked segment URLs remain remote in the playlist passed to ffmpeg, while audio and webhook paths make remote requests without checking redirect targets.

### Issue 2
apps/media-server/src/lib/media-probe.ts:129-131
`probeVideo` now awaits `resolveAllowedUrl` before registering the probe or starting its 30-second timeout. The helper sends HEAD and GET requests without an abort signal. If a host stalls on HEAD, the request waits outside the timeout and shutdown drain count.

Concurrent requests can also all pass the six-probe limit before this wait finishes, then start together. Reserve the operation before this network work and include it in the timeout. Pass an abort signal into `resolveAllowedUrl`.

### Issue 3
packages/database/emails/config.ts:77-79
`sendEmail` now requires `RESEND_FROM_DOMAIN` even when `fromOverride` supplies a complete sender address. If a self-host has a working Resend key but no default sender domain, account-deletion and content-report notifications now return `email_not_configured` before reaching their `Cap Support <richie@send.cap.so>` override. Those messages previously reached Resend.

Check the sender requirement against the actual sender used for the message, so an explicit sender does not require an unused default domain.

### Issue 4
apps/web/content/docs/self-hosting.mdx:135
The new documentation says `MEDIA_SERVER_ALLOW_PRIVATE_URLS=true` allows `file:` URLs. But `assertUrlAllowed` rejects non-HTTP schemes before checking that setting, and the added test explicitly requires this rejection. Operators following this advice will still receive `URL_NOT_ALLOWED`.

Remove the claim that the setting allows local files.

---

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

Summary

This PR hardens self-host startup, adds media URL checks and shutdown draining, and splits the embed player into smaller chunks.

  • The media server checks each URL before it uses it.
  • Self-hosted installs require unique secrets before they start.
  • The media server drains active work before it stops.
  • Email callers can tell when delivery is not set up.
  • Embed pages load player code only when they need it.
  • Direct video conversions scale their default limit with CPU count.
  • cap-web reports media-server connection problems after startup.
  • Thumbnail requests keep defaults for options they leave out.

Acknowledgments:

  • 07rjain explicitly documents the DNS check/use race as a known limitation; it is not reported.
  • 07rjain intentionally supports login codes in server logs when email is unconfigured.
  • 07rjain intentionally keeps saved invites and returns not_configured when email is unavailable.

Reviews (1) · Last reviewed commit: "fix(media-server): close the URL and dra..." · Reviewed by Greptile

07rjain and others added 10 commits October 14, 2026 11:53
…optional host allow-list and compare the secret in constant time
Trusted endpoints match host and port, and every redirect hop is checked before fetch or ffmpeg sees it. Drain keeps /health at 200 and waits for probe and audio work, and a missing mail setup reports not_configured instead of failing the invite.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Oct 14, 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 +324 to +325
await assertUrlAllowed(resolved);
return resolved;

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 bypass URL checks

materializeHlsPlaylist checks a segment URL but leaves that remote URL in the local playlist. If a public segment redirects to http://127.0.0.1:3000/, ffmpeg follows it without another policy check. Audio extraction, webhook delivery, and storage uploads also still bypass redirect checks.

Route every remote request through the policy, including playlist resources and uploads. Checking a URL before handing it to ffmpeg is not enough.

How this was verified: Checked segment URLs remain remote in the playlist passed to ffmpeg, while audio and webhook paths make remote requests without checking redirect targets.

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

Comment:
**Redirects bypass URL checks**

`materializeHlsPlaylist` checks a segment URL but leaves that remote URL in the local playlist. If a public segment redirects to `http://127.0.0.1:3000/`, ffmpeg follows it without another policy check. Audio extraction, webhook delivery, and storage uploads also still bypass redirect checks.

Route every remote request through the policy, including playlist resources and uploads. Checking a URL before handing it to ffmpeg is not enough.

**How this was verified:** Checked segment URLs remain remote in the playlist passed to ffmpeg, while audio and webhook paths make remote requests without checking redirect targets.

---

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

Comment on lines +129 to +131
const allowedUrl = await resolveAllowedUrl(videoUrl);
return await withMediaOperation("probe", () =>
withTimeout(probeMedia(videoUrl), PROBE_TIMEOUT_MS),
withTimeout(probeMedia(allowedUrl), PROBE_TIMEOUT_MS),

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 Probes wait outside their limits

probeVideo now awaits resolveAllowedUrl before registering the probe or starting its 30-second timeout. The helper sends HEAD and GET requests without an abort signal. If a host stalls on HEAD, the request waits outside the timeout and shutdown drain count.

Concurrent requests can also all pass the six-probe limit before this wait finishes, then start together. Reserve the operation before this network work and include it in the timeout. Pass an abort signal into resolveAllowedUrl.

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

Comment:
**Probes wait outside their limits**

`probeVideo` now awaits `resolveAllowedUrl` before registering the probe or starting its 30-second timeout. The helper sends HEAD and GET requests without an abort signal. If a host stalls on HEAD, the request waits outside the timeout and shutdown drain count.

Concurrent requests can also all pass the six-probe limit before this wait finishes, then start together. Reserve the operation before this network work and include it in the timeout. Pass an abort signal into `resolveAllowedUrl`.

---

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

Comment thread packages/database/emails/config.ts Outdated
Comment on lines +77 to +79
const status = getEmailDeliveryStatus();
const r = resend();
if (!r) {
return Promise.resolve();
if (!status.configured || !r) {

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 Explicit senders stop working

sendEmail now requires RESEND_FROM_DOMAIN even when fromOverride supplies a complete sender address. If a self-host has a working Resend key but no default sender domain, account-deletion and content-report notifications now return email_not_configured before reaching their Cap Support <richie@send.cap.so> override. Those messages previously reached Resend.

Check the sender requirement against the actual sender used for the message, so an explicit sender does not require an unused default domain.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 77-79

Comment:
**Explicit senders stop working**

`sendEmail` now requires `RESEND_FROM_DOMAIN` even when `fromOverride` supplies a complete sender address. If a self-host has a working Resend key but no default sender domain, account-deletion and content-report notifications now return `email_not_configured` before reaching their `Cap Support <richie@send.cap.so>` override. Those messages previously reached Resend.

Check the sender requirement against the actual sender used for the message, so an explicit sender does not require an unused default domain.

---

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

Comment thread apps/web/content/docs/self-hosting.mdx Outdated
- Trusted hosts are those in `S3_INTERNAL_ENDPOINT`, `S3_PUBLIC_ENDPOINT`, `CAP_AWS_ENDPOINT`, `MEDIA_SERVER_WEBHOOK_URL` and `WEB_URL` (set them on the media server too), plus anything in `MEDIA_SERVER_ALLOWED_HOSTS`.
- When `MEDIA_SERVER_ALLOWED_HOSTS` is set, it becomes a strict allow-list: every URL must use one of those hosts or the trusted hosts above. Entries are comma-separated and may use a `*.` prefix, e.g. `minio.railway.internal,*.r2.cloudflarestorage.com`.

If your storage lives on a private network (MinIO in Docker or on Railway's private network), set `S3_INTERNAL_ENDPOINT` on the media server or add its host to `MEDIA_SERVER_ALLOWED_HOSTS`. Otherwise processing fails with `URL_NOT_ALLOWED`. `MEDIA_SERVER_ALLOW_PRIVATE_URLS=true` turns the private-address check off and also allows `file:` URLs; it's meant for local development and tests only.

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 Local-file advice cannot work

The new documentation says MEDIA_SERVER_ALLOW_PRIVATE_URLS=true allows file: URLs. But assertUrlAllowed rejects non-HTTP schemes before checking that setting, and the added test explicitly requires this rejection. Operators following this advice will still receive URL_NOT_ALLOWED.

Remove the claim that the setting allows local files.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/content/docs/self-hosting.mdx
Line: 135

Comment:
**Local-file advice cannot work**

The new documentation says `MEDIA_SERVER_ALLOW_PRIVATE_URLS=true` allows `file:` URLs. But `assertUrlAllowed` rejects non-HTTP schemes before checking that setting, and the added test explicitly requires this rejection. Operators following this advice will still receive `URL_NOT_ALLOWED`.

Remove the claim that the setting allows local files.

---

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

07rjain and others added 3 commits October 14, 2026 20:35
Playlist and audio inputs are downloaded before ffmpeg runs, and uploads and webhooks check each redirect hop. Probes reserve a slot before that check, and an explicit email sender no longer depends on an unused default domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
A remote thumbnail input was still opened by ffmpeg, so a redirect to a private address was fetched. The file is downloaded through the URL check first, and cancellation during that download still aborts the request.

Co-authored-by: Cursor <cursoragent@cursor.com>
ffmpeg was still opening the recording URL, so a redirect to a private address was fetched. The bytes are downloaded through the URL check first, and cancellation or the verification deadline still stops that download.

Co-authored-by: Cursor <cursoragent@cursor.com>

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