DR-235 phase 3. Every video renderer is native now: mpv on Linux and Windows, ExoPlayer on Android, all drawing behind the transparent webview. The HTML5 <video> path is gone, not bypassed: - Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the createAdapter factory, streamTransport, hlsRecovery, timeTracking, videoFit, the <video>/<track> markup and every element handler in VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one video adapter; webview audio gets its own adapter kind. - Rust: use_html5 dropped from player_seek_video, player_switch_audio_track and player_set_stream_quality with the Html5* strategies and ReloadStream responses; use_html5_element and VideoBackend dropped from PlayerStatus; player_play_item always loads the backend (set_current_item removed); Capabilities::webview removed; the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed. - Android: the HTML5 video state in PictureInPictureManager and ScreenWakeManager, and the bridge method feeding it. - CSP: connect-src loses http:/https: and worker-src loses blob: - both existed for hls.js; with it gone they were only an exfiltration channel and a blob worker for injected script. A test now keeps them out. mpv takes over what the <video> element did (mpv_tracks, UT-275): subtitles are the WebVTT list the play request carries, queued on sub-files and selected by position in that list, starting off; audio tracks are selected by position in the file; sid/aid are reset before each load. Without this, Linux video had no subtitle selection and a direct-play audio switch failed since mpv became its renderer. Verified: Rust 948 passing, and the same 948 cross-compiled for Windows under wine against the shipped DLL (track tests included); frontend 1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI ratchet tightened to match. Not yet seen on Windows hardware.
168 lines
9.6 KiB
Markdown
168 lines
9.6 KiB
Markdown
# Security
|
|
|
|
## Authentication Token Storage
|
|
|
|
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
LoginSuccess["Login Success"]
|
|
KeyringCheck{"System Keyring<br/>Available?"}
|
|
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
|
|
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
|
|
|
|
LoginSuccess --> KeyringCheck
|
|
KeyringCheck -->|"Yes"| OSCredential
|
|
KeyringCheck -->|"No"| EncryptedFallback
|
|
```
|
|
|
|
**Key Format:**
|
|
```
|
|
jellytau::{server_id}::{user_id}::access_token
|
|
```
|
|
|
|
**Rationale:**
|
|
- Tokens in SQLite would be readable if the database file is accessed
|
|
- System keyrings provide OS-level encryption and access control
|
|
- Fallback ensures functionality on minimal systems without a keyring daemon
|
|
|
|
## Secure Storage Module
|
|
|
|
**Location**: `src-tauri/src/secure_storage/` (planned)
|
|
|
|
```rust
|
|
pub trait SecureStorage: Send + Sync {
|
|
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
|
|
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
|
|
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
|
|
}
|
|
|
|
// Platform implementations
|
|
pub struct KeyringStorage; // Uses keyring crate
|
|
pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
|
```
|
|
|
|
## Network Security
|
|
|
|
| Aspect | Implementation |
|
|
|--------|----------------|
|
|
| Transport | HTTPS required for all Jellyfin API calls |
|
|
| Certificate Validation | System CA store (configurable for self-signed) |
|
|
| Token Transmission | Bearer token in `Authorization` header only |
|
|
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
|
| Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority |
|
|
| Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together |
|
|
|
|
## Webview Content Security Policy
|
|
|
|
`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was
|
|
`null` — CSP disabled — which meant any script that reached the web layer
|
|
inherited the full IPC surface. Tauri computes the header from this value when it
|
|
serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap
|
|
script, so `script-src` needs no `'unsafe-inline'`.
|
|
|
|
```
|
|
default-src 'self';
|
|
script-src 'self';
|
|
style-src 'self' 'unsafe-inline';
|
|
font-src 'self' data:;
|
|
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
|
|
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
|
|
connect-src 'self' ipc: http://ipc.localhost;
|
|
worker-src 'self';
|
|
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
|
```
|
|
|
|
| Directive | Why |
|
|
|-----------|-----|
|
|
| `default-src 'self'` | Everything not named below is same-origin only. |
|
|
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
|
|
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
|
|
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
|
|
| `media-src` | `<audio>` sources for the webview audio backend (a desktop without mpv; no shipped platform): streams from the server and the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137). Video never plays in the webview (DR-235). |
|
|
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. Nothing else: all network traffic goes through Rust. It allowed any `http:`/`https:` host while hls.js fetched manifests and segments in the page; with hls.js gone (DR-235) that grant was only an exfiltration channel for injected script, so it went too. |
|
|
| `worker-src 'self'` | No blob workers since hls.js (whose demuxer ran in one) was removed (DR-235). |
|
|
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
|
|
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
|
|
|
|
**`img-src`/`media-src` are deliberately permissive.** The Jellyfin
|
|
origin is typed in by the user at run time and is routinely plain `http` on a
|
|
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
|
|
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
|
|
does not touch `script-src`, which is where an injected origin would actually
|
|
hurt. A run-time policy naming the server exactly was considered and rejected:
|
|
Tauri derives the header from immutable config at the moment it serves the HTML,
|
|
so it would mean rebuilding the config and reloading the webview whenever the
|
|
user adds or switches a server, to constrain a destination the user chooses
|
|
anyway.
|
|
|
|
`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src`
|
|
and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and
|
|
code and drives HMR over a websocket. It applies only to `tauri dev`.
|
|
|
|
### Asset protocol scope
|
|
|
|
`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage
|
|
root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend:
|
|
downloaded media moved to the loopback media server in DR-137, and downloaded
|
|
audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**`
|
|
grant let the webview read the SQLite database and the encrypted-token fallback
|
|
file alongside the thumbnails it actually needs.
|
|
|
|
If a new feature hands the webview a local file, widen this scope to that
|
|
subdirectory specifically; a path outside it resolves to nothing and the webview
|
|
reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
|
|
|
|
## Local Data Protection
|
|
|
|
| Data Type | Protection |
|
|
|-----------|------------|
|
|
| Access Tokens | System keyring or encrypted file |
|
|
| Database (SQLite) | Plaintext (metadata only, no secrets) |
|
|
| Downloaded Media | Filesystem permissions only |
|
|
| Cached Thumbnails | Filesystem permissions only |
|
|
|
|
## Path Confinement and Input Binding
|
|
|
|
Two classes of defect, both of the same *shape*: a value that arrived from
|
|
outside decided something it should not, at a site whose neighbours a few lines
|
|
away already did it correctly.
|
|
|
|
### Filesystem path confinement
|
|
|
|
| Surface | Rule | TRACES |
|
|
|---------|------|--------|
|
|
| Thumbnail cache | The filename is built from `item_id`, `image_type` and `tag`; all three are sanitised (non-alphanumerics → `_`), and the resolved path is checked with `starts_with(cache_dir)` **at the point of use** | DR-210 |
|
|
| Downloads | `file_path` and `target_dir` are sanitised inside `download_item` itself, not only in `download_item_and_start` — the latter is what made the existing guard bypassable rather than absent | DR-211 |
|
|
|
|
Two mechanics worth remembering, because both are easy to get subtly wrong:
|
|
|
|
- `Path::join` **neither folds `..` nor keeps the base when handed an absolute
|
|
path**. Confinement therefore has to be checked *after* the join, not before.
|
|
- Sanitising is **per path component**. Whole-string sanitising would rewrite
|
|
`downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download.
|
|
|
|
The database keeps both the raw key and the resolved path, so lookups still match
|
|
and pre-existing rows still resolve.
|
|
|
|
### Query and URL construction
|
|
|
|
Caller-supplied values are **bound or encoded**, never interpolated (DR-212):
|
|
|
|
- The offline `get_items` item-type filter uses parameter placeholders rather
|
|
than formatting `IN ('a','b')`.
|
|
- `build_get_items_endpoint` encodes `ParentId` / `IncludeItemTypes` / `SortBy` /
|
|
`SortOrder`. Encoding is **per element** and list separators stay unencoded,
|
|
because Jellyfin splits these parameters on the comma.
|
|
- `player_set_volume` clamps at the command boundary — it previously accepted
|
|
NaN and out-of-range floats even though every backend clamps internally.
|
|
|
|
## Security Considerations
|
|
|
|
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
|
|
2. **Token Isolation**: Each user/server combination has a separate token entry
|
|
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
|
4. **No Token Logging**: Tokens are never written to logs or debug output
|
|
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
|
6. **Webview Containment**: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
|