Skip to content

feat(playback): secure remote casting controls (DLNA + Cast)#1069

Closed
SalemOurabi wants to merge 9 commits into
4gray:masterfrom
SalemOurabi:split/casting-controls
Closed

feat(playback): secure remote casting controls (DLNA + Cast)#1069
SalemOurabi wants to merge 9 commits into
4gray:masterfrom
SalemOurabi:split/casting-controls

Conversation

@SalemOurabi

Copy link
Copy Markdown
Contributor

Summary

Secure remote casting controls for the inline web player, split out of #1056 (casting topic only).

  • Cast control UI (libs/ui/playback/src/lib/casting/): cast-control component, cast.service, cast-media.utils, Google Cast types, and a cast-control-visibility helper that auto-hides the overlay when idle and removes it from the focus order / a11y tree while inactive.
  • DLNA renderer discovery & control (apps/electron-backend/src/app/services/dlna-*): protocol + XML helpers and the renderer service, exposed via casting.events IPC.
  • External-player session registry plus mpv/vlc session services for remote playback hand-off.
  • Runtime capability detection (runtime-capabilities.service) so the control only renders where casting is supported.
  • Mounts the control into the workspace shell and the inline player surfaces (m3u / xtream / stalker live tabs).
  • Hardens dev-startup coordination for the Electron serve script.

Tests

New unit specs for cast-control, cast.service, cast-media.utils, cast-control-visibility, casting.events, dlna-renderer.service, external-player-session-registry, mpv-session.service, and web-player-view overlay/accessibility behavior.

Verified locally on top of upstream/master: nx build electron-backend compiles; affected lint clean (no new errors — only pre-existing upstream warnings); affected test green.

Context

Part of splitting the oversized #1056 into reviewable, topic-scoped PRs. Companion PRs: workspace navigation rail and xtream IPC hardening.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds secure remote casting (DLNA, Google Cast, AirPlay, W3C Remote Playback) to the inline web player, with companion SSRF hardening, a new Electron dev-server coordination script, and requiresRequestHeaders session metadata that prevents offering DLNA/Cast when custom HTTP headers are required.

  • DLNA service (dlna-protocol.ts, dlna-renderer.service.ts, dlna-xml.ts): SSDP discovery, request-pinning to the verified SSDP responder IP (preventing SSRF), size-bounded responses, and UPnP SOAP control with XML escaping.
  • Cast control (cast.service.ts, cast-control.component.ts, cast-control-visibility.ts): Angular service and UI component surfaced in the web player, audio player, and external-player dock; lazy-loads the Google Cast SDK; auto-hides overlay when idle with correct a11y inert/aria-hidden handling.
  • MPV/VLC refactor: buildMpvReusePropertyCommands now actively resets all three HTTP properties (user-agent, referrer, header-fields) to empty strings when reusing an instance, deliberately clearing headers from the previous stream.

Confidence Score: 4/5

Safe to merge with low risk; all findings are non-blocking quality and security-posture items.

The DLNA service's SSRF protections (request-pinning, private-only SSDP validation, receiver URL allow/block list, payload validation) are thorough and well-tested. The Google Cast integration correctly restricts to secure PWA contexts. The main concerns are: the CSP relaxation that permits gstatic.com scripts in the Electron renderer where Cast never runs; the absolute request timeout that is redundant at the same duration as the socket inactivity timeout; and the toCastPlayback plain-method binding that produces a new object reference on every change-detection cycle under OnPush. None of these affect correctness or security critically.

apps/web/src/index.html (CSP applies to Electron renderer), apps/electron-backend/src/app/services/dlna-protocol.ts (redundant absolute timeout), and libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts (plain method in template binding under OnPush).

Security Review

  • CSP widened in both PWA and Electron renderer (apps/web/src/index.html): script-src now includes https://www.gstatic.com. Required for Google Cast in the PWA; unnecessarily applies to the Electron renderer where Cast is never invoked.
  • SSRF protections for DLNA are correctly implemented: SSDP responder address is validated to a private IPv4 range before fetching; HTTP requests are pinned to the verified responder IP via requestPinnedText (bypasses DNS-rebinding); response size is capped at 512 KB; absolute and inactivity timeouts are both enforced.
  • IPC payload validation: startPlayback validates device ID, stream URL (isReceiverFetchableUrl), payload shape, and presence of custom headers (hasPlaybackHeaders). Malformed renderer input returns a typed error result rather than throwing.
  • XML injection prevention: buildUpnpActionBody escapes all five XML special characters in values written into the SOAP body.
  • Google Cast SDK is loaded from the official Google CDN (gstatic.com) over HTTPS only when Cast is explicitly initiated in a secure PWA context; the script is never loaded in Electron.

Important Files Changed

Filename Overview
apps/electron-backend/src/app/services/dlna-renderer.service.ts Core DLNA renderer discovery and playback control. Security measures (request-pinning, receiver-URL allow/block lists, IPC payload validation) are well-applied; absolute request timeout is redundant at current configuration.
apps/electron-backend/src/app/services/dlna-protocol.ts SSDP parsing, SSRF guards, and pinned HTTP helper. The enforceAbsoluteRequestTimeout is called with the same 4000ms as req.setTimeout, making it redundant against trickle-data attacks.
libs/ui/playback/src/lib/casting/cast.service.ts Google Cast SDK lazy-loading, AirPlay, Remote Playback, and DLNA orchestration. SDK load promise is properly deduplicated and resets on failure; canUseGoogleCast correctly restricts to secure PWA contexts.
apps/web/src/index.html CSP extended to allow scripts from https://www.gstatic.com for the Google Cast SDK. Relaxation is necessary for PWA Cast support but also applies to the Electron renderer where Cast is never used.
apps/electron-backend/src/app/events/casting.events.ts Registers two IPC handlers for DLNA discovery and playback; module-level DlnaRendererService singleton is appropriate and each handler delegates validation to the service.
libs/ui/playback/src/lib/casting/cast-media.utils.ts findCastMediaElement traverses the DOM using hardcoded class selectors (.web-player-view, .radio-hero) which creates implicit coupling to component class names.
libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts toCastPlayback() is a plain method called in the template, producing a new object reference on every change-detection cycle; with OnPush the cast-control child re-evaluates its signal-computed properties unnecessarily each cycle.
apps/electron-backend/src/app/events/mpv-session.service.ts Refactors header resolution to use buildMpvReusePropertyCommands; now actively clears user-agent/referrer/header-fields when switching streams, which is intentional and tested.
apps/electron-backend/src/app/events/external-player-session-registry.ts Fixes a bug where return inside a finally block silently swallowed close() exceptions; now returns getSession(id) after the try-finally, propagating errors correctly.
tools/electron/serve-electron-dev.runtime.mjs Dev-server coordination: port-availability check, IPTVnator-specific readiness probe, and bidirectional child-process lifecycle management; well-tested with node:test.
apps/electron-backend/src/app/services/dlna-xml.ts SAX-based XML parsing for renderer device descriptions; XML escaping in buildUpnpActionBody is complete and correct.

Sequence Diagram

sequenceDiagram
    participant U as User (UI)
    participant CC as CastControlComponent
    participant CS as CastService
    participant EP as ElectronBridge (IPC)
    participant CE as CastingEvents (main)
    participant DR as DlnaRendererService
    participant DV as DLNA Device

    U->>CC: opens cast menu
    CC->>CS: discoverDlnaDevices()
    CS->>EP: window.electron.discoverDlnaRenderers()
    EP->>CE: IPC CAST:DLNA_DISCOVER
    CE->>DR: discover(2200ms)
    DR-->>DV: SSDP M-SEARCH (UDP multicast 239.255.255.250)
    DV-->>DR: SSDP 200 OK (location, USN)
    DR->>DR: isTrustedSsdpLocation (private IP only)
    DR->>DV: requestPinnedText(location, responderIP)
    DR->>DR: parseRendererDescription (SAX)
    DR->>DR: cache CachedRenderer (5 min TTL)
    DR-->>CE: DlnaRendererDevice[]
    CE-->>EP: IPC result
    EP-->>CS: DlnaRendererDevice[]
    CS-->>CC: device list

    U->>CC: selects DLNA device
    CC->>CS: startDlnaPlayback(deviceId, playback)
    CS->>EP: window.electron.startDlnaPlayback(deviceId, playback)
    EP->>CE: IPC CAST:DLNA_START
    CE->>DR: startPlayback(deviceId, playback)
    DR->>DR: validate payload + isReceiverFetchableUrl + hasPlaybackHeaders
    DR->>DV: SetAVTransportURI (SOAP POST, pinned to device IP)
    DR->>DV: Play (SOAP POST, pinned to device IP)
    DR-->>CE: success: true
    CE-->>EP: IPC result
    EP-->>CC: success
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
apps/web/src/index.html:9-12
**CSP relaxation applies to both PWA and Electron renderer**

`https://www.gstatic.com` has been added to `script-src`, which is required to load the Google Cast SDK in the PWA. However, this same `index.html` is served in the Electron renderer, where `canUseGoogleCast` is always `false` (guarded by `isPwa`) and the SDK is never fetched. A script-origin that is intentionally unused in one build still widens the CSP surface for that build. Consider applying the gstatic allowance only in the PWA configuration — for example via an Nx fileReplacement or a build-time `<meta>` injection — so the Electron renderer retains the tighter `script-src 'self'`.

### Issue 2 of 4
apps/electron-backend/src/app/services/dlna-protocol.ts:126-135
**`enforceAbsoluteRequestTimeout` provides no additional protection at the same deadline as `req.setTimeout`**

Both `req.setTimeout(DLNA_REQUEST_TIMEOUT_MS, …)` and `enforceAbsoluteRequestTimeout(req)` (which defaults to `DLNA_REQUEST_TIMEOUT_MS = 4000 ms`) fire at the same wall-clock moment. The absolute timeout exists to guard against trickle-data attacks where a server sends a byte every few milliseconds to keep the socket inactivity timer from expiring. With both set to 4000 ms they fire simultaneously and the absolute timeout adds no protection. The absolute deadline should be set to a longer value — e.g., `enforceAbsoluteRequestTimeout(req, 10_000)` — while the socket inactivity timeout stays short, so a slow sender is still terminated within a bounded wall-clock window.

### Issue 3 of 4
libs/ui/playback/src/lib/casting/cast-media.utils.ts:12-20
**`findCastMediaElement` is implicitly coupled to component CSS class names**

The DOM traversal relies on the hardcoded selectors `.web-player-view` and `.radio-hero`. If either class name is ever renamed or the layout is restructured, Cast silently stops finding the `<video>`/`<audio>` element — no type error, no lint warning. Consider using a `data-cast-scope` attribute set explicitly on the host elements, or alternatively accept the container element as a typed parameter from the component (which already has a reference via `ElementRef`), so the coupling is explicit and survives CSS refactors.

### Issue 4 of 4
libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts:53-62
**`toCastPlayback` creates a new object reference on every change-detection cycle**

`CastControlComponent` uses `ChangeDetectionStrategy.OnPush`. Since `toCastPlayback(session)` is a plain method called in the template binding, Angular evaluates it on every CD run and passes a fresh object reference each time. OnPush compares inputs by reference, so the cast-control child always appears to have a changed `playback` input and will re-evaluate all its signal-computed properties on every cycle. Converting this to a `computed()` signal in the component (keyed off `facade.activeExternalSession()`) would produce stable references and avoid redundant re-evaluation.

Reviews (1): Last reviewed commit: "fix(playback): hide inactive cast contro..." | Re-trigger Greptile

Comment thread apps/web/src/index.html
Comment on lines 9 to 12
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'self'; object-src 'none'; frame-src 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: file: data: blob:; font-src 'self' data:; media-src 'self' http: https: file: data: blob:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:"
content="default-src 'self'; base-uri 'self'; object-src 'none'; frame-src 'none'; form-action 'self'; script-src 'self' https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: file: data: blob:; font-src 'self' data:; media-src 'self' http: https: file: data: blob:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security CSP relaxation applies to both PWA and Electron renderer

https://www.gstatic.com has been added to script-src, which is required to load the Google Cast SDK in the PWA. However, this same index.html is served in the Electron renderer, where canUseGoogleCast is always false (guarded by isPwa) and the SDK is never fetched. A script-origin that is intentionally unused in one build still widens the CSP surface for that build. Consider applying the gstatic allowance only in the PWA configuration — for example via an Nx fileReplacement or a build-time <meta> injection — so the Electron renderer retains the tighter script-src 'self'.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/src/index.html
Line: 9-12

Comment:
**CSP relaxation applies to both PWA and Electron renderer**

`https://www.gstatic.com` has been added to `script-src`, which is required to load the Google Cast SDK in the PWA. However, this same `index.html` is served in the Electron renderer, where `canUseGoogleCast` is always `false` (guarded by `isPwa`) and the SDK is never fetched. A script-origin that is intentionally unused in one build still widens the CSP surface for that build. Consider applying the gstatic allowance only in the PWA configuration — for example via an Nx fileReplacement or a build-time `<meta>` injection — so the Electron renderer retains the tighter `script-src 'self'`.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +126 to +135
let size = 0;
response.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_DLNA_RESPONSE_BYTES) {
req.destroy(
new Error('DLNA response exceeded the size limit.')
);
return;
}
chunks.push(chunk);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 enforceAbsoluteRequestTimeout provides no additional protection at the same deadline as req.setTimeout

Both req.setTimeout(DLNA_REQUEST_TIMEOUT_MS, …) and enforceAbsoluteRequestTimeout(req) (which defaults to DLNA_REQUEST_TIMEOUT_MS = 4000 ms) fire at the same wall-clock moment. The absolute timeout exists to guard against trickle-data attacks where a server sends a byte every few milliseconds to keep the socket inactivity timer from expiring. With both set to 4000 ms they fire simultaneously and the absolute timeout adds no protection. The absolute deadline should be set to a longer value — e.g., enforceAbsoluteRequestTimeout(req, 10_000) — while the socket inactivity timeout stays short, so a slow sender is still terminated within a bounded wall-clock window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/electron-backend/src/app/services/dlna-protocol.ts
Line: 126-135

Comment:
**`enforceAbsoluteRequestTimeout` provides no additional protection at the same deadline as `req.setTimeout`**

Both `req.setTimeout(DLNA_REQUEST_TIMEOUT_MS, …)` and `enforceAbsoluteRequestTimeout(req)` (which defaults to `DLNA_REQUEST_TIMEOUT_MS = 4000 ms`) fire at the same wall-clock moment. The absolute timeout exists to guard against trickle-data attacks where a server sends a byte every few milliseconds to keep the socket inactivity timer from expiring. With both set to 4000 ms they fire simultaneously and the absolute timeout adds no protection. The absolute deadline should be set to a longer value — e.g., `enforceAbsoluteRequestTimeout(req, 10_000)` — while the socket inactivity timeout stays short, so a slow sender is still terminated within a bounded wall-clock window.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +12 to +20
controlElement: Element
): RemotePlaybackMediaElement | null {
const host =
controlElement.closest('.web-player-view, .radio-hero') ??
controlElement.parentElement;

return (
host?.querySelector<RemotePlaybackMediaElement>('video, audio') ?? null
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 findCastMediaElement is implicitly coupled to component CSS class names

The DOM traversal relies on the hardcoded selectors .web-player-view and .radio-hero. If either class name is ever renamed or the layout is restructured, Cast silently stops finding the <video>/<audio> element — no type error, no lint warning. Consider using a data-cast-scope attribute set explicitly on the host elements, or alternatively accept the container element as a typed parameter from the component (which already has a reference via ElementRef), so the coupling is explicit and survives CSS refactors.

Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/ui/playback/src/lib/casting/cast-media.utils.ts
Line: 12-20

Comment:
**`findCastMediaElement` is implicitly coupled to component CSS class names**

The DOM traversal relies on the hardcoded selectors `.web-player-view` and `.radio-hero`. If either class name is ever renamed or the layout is restructured, Cast silently stops finding the `<video>`/`<audio>` element — no type error, no lint warning. Consider using a `data-cast-scope` attribute set explicitly on the host elements, or alternatively accept the container element as a typed parameter from the component (which already has a reference via `ElementRef`), so the coupling is explicit and survives CSS refactors.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 53 to +62
readonly facade = inject(WorkspaceShellFacade);
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);

toCastPlayback(session: ExternalPlayerSession): ResolvedPortalPlayback {
return {
streamUrl: session.streamUrl,
title: session.title,
thumbnail: session.thumbnail,
isLive: !session.contentInfo,
requiresRequestHeaders: session.requiresRequestHeaders,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 toCastPlayback creates a new object reference on every change-detection cycle

CastControlComponent uses ChangeDetectionStrategy.OnPush. Since toCastPlayback(session) is a plain method called in the template binding, Angular evaluates it on every CD run and passes a fresh object reference each time. OnPush compares inputs by reference, so the cast-control child always appears to have a changed playback input and will re-evaluate all its signal-computed properties on every cycle. Converting this to a computed() signal in the component (keyed off facade.activeExternalSession()) would produce stable references and avoid redundant re-evaluation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
Line: 53-62

Comment:
**`toCastPlayback` creates a new object reference on every change-detection cycle**

`CastControlComponent` uses `ChangeDetectionStrategy.OnPush`. Since `toCastPlayback(session)` is a plain method called in the template binding, Angular evaluates it on every CD run and passes a fresh object reference each time. OnPush compares inputs by reference, so the cast-control child always appears to have a changed `playback` input and will re-evaluate all its signal-computed properties on every cycle. Converting this to a `computed()` signal in the component (keyed off `facade.activeExternalSession()`) would produce stable references and avoid redundant re-evaluation.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The casting change to video-player.component.html binds [playback] on the
inline player child; the spec's mock needs the matching input() declared or
the template binding fails. (Carried over from the 4gray#1056 merge resolution
that the topic cherry-pick missed.)
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 63.08851% with 196 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.73%. Comparing base (e91cbd5) to head (869bae0).
⚠️ Report is 1923 commits behind head on master.

Files with missing lines Patch % Lines
...-backend/src/app/services/dlna-renderer.service.ts 34.73% 59 Missing and 3 partials ⚠️
libs/ui/playback/src/lib/casting/cast.service.ts 54.90% 38 Missing and 8 partials ⚠️
...electron-backend/src/app/services/dlna-protocol.ts 57.89% 34 Missing and 6 partials ⚠️
...playback/src/lib/casting/cast-control.component.ts 81.66% 7 Missing and 4 partials ⚠️
...bs/ui/playback/src/lib/casting/cast-media.utils.ts 71.42% 8 Missing and 2 partials ⚠️
apps/electron-backend/src/app/services/dlna-xml.ts 83.67% 2 Missing and 6 partials ⚠️
apps/electron-backend/src/app/api/main.preload.ts 0.00% 4 Missing ⚠️
...shared/interfaces/src/lib/portal-playback.utils.ts 0.00% 4 Missing ⚠️
.../electron-backend/src/app/events/casting.events.ts 72.72% 3 Missing ⚠️
...src/app/events/external-player-playback-request.ts 50.00% 0 Missing and 2 partials ⚠️
... and 4 more
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

❗ There is a different number of reports uploaded between BASE (e91cbd5) and HEAD (869bae0). Click for more details.

HEAD has 4 uploads less than BASE
Flag BASE (e91cbd5) HEAD (869bae0)
4 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #1069       +/-   ##
===========================================
- Coverage   71.05%   54.73%   -16.32%     
===========================================
  Files          40      549      +509     
  Lines         691    30424    +29733     
  Branches       87     6502     +6415     
===========================================
+ Hits          491    16654    +16163     
- Misses        176    11265    +11089     
- Partials       24     2505     +2481     
Flag Coverage Δ
unit 54.73% <63.08%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@SalemOurabi

Copy link
Copy Markdown
Contributor Author

Closing — consolidating this split back into #1056 at the author's request. Superseded by the combined PR.

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.

2 participants