# Security


This document describes the security model of the FFmpeg Media module.

Companion document:

- `docs/THREAT_MODEL.md` (formal STRIDE model)

## Command Execution

All FFmpeg/FFprobe commands are executed via **Symfony Process** with array-based arguments. This prevents shell injection since arguments are passed directly to the process without shell interpretation.

- `FFmpegExecutor::execute()` uses `new Process($commandArray)` exclusively
- All processor plugins use array-based command construction
- String commands are parsed into arrays by `normalizeCommand()` with proper quote handling
- **No `shell_exec`, `exec`, `system`, or `passthru` calls exist in production code**

The `buildCommandArray()` method constructs commands as arrays:

```php
// Safe: array-based, no shell interpretation
$process = new Process(['/usr/bin/ffmpeg', '-i', $input, '-c:v', 'libx264', $output]);
```

## Input Validation

### Path Validation

- **Null byte rejection**: Paths containing null bytes are rejected
- **Traversal sequence rejection**: Paths containing `../` or `..\` sequences are rejected
- **Stream wrapper allowlist**: Only `public://`, `private://`, and `temporary://` wrappers are permitted
- **Absolute path requirement**: Non-stream-wrapper paths must be absolute (starting with `/` on Unix or a drive letter on Windows); relative paths are rejected
- **`basename()`** strips directory components from user-supplied filenames
- **Stream wrapper validation**: SettingsForm requires `://` (stream wrapper) or absolute paths (`/`) for the trash directory setting, preventing relative path traversal

### Value Object Validation

`ConversionOptions` validates all parameters in the constructor:

- CRF: 0-63 range
- Preset: 0-9 range
- Threads: non-negative
- Timeout: positive or NULL

Invalid values throw `\InvalidArgumentException` before reaching FFmpeg.

### Form Validation

`SettingsForm::validateForm()` checks:

- FFmpeg/FFprobe paths are executable (`is_executable()`)
- Retention days within 1-90 range
- Trash directory is a valid stream wrapper URI or absolute path

## File Handling

- Output paths are constructed using `ConversionFileManager` with `pathinfo()` + controlled directory structures
- No user input flows directly into output file paths
- Temporary files are cleaned up on conversion failure via `ConversionFileManager::cleanupTempFiles()` and periodically via cron; cleanup is not guaranteed on all failure paths (e.g., process crashes)
- Drupal's `FileSystem` service handles file permissions via stream wrappers

## Credential Security

- CDN secret keys use `#type => 'password'` form elements (masked in UI)
- Secrets are not echoed as default values in forms
- Credential resolution order is: environment variables -> Key module references -> State API -> legacy config fallback
- AWS S3 signatures use HMAC-SHA256 key derivation per AWS Signature V4

### CDN Credential Migration Guide

**Deprecated since:** `ffmpeg_media:1.0.0-alpha7`
**Removal target:** `ffmpeg_media:2.0.0`

Legacy CDN credentials stored in Drupal's exportable configuration
(`ffmpeg_media.cdn`) are deprecated. Reading `access_key` and `secret_key` from
config triggers `E_USER_DEPRECATED` and a log warning. Migrate before upgrading
to 2.0.0.

**Credential resolution order** (first match wins):

1. **Environment variables** (recommended for production):
   - `FFMPEG_MEDIA_S3_ACCESS_KEY`
   - `FFMPEG_MEDIA_S3_SECRET_KEY`
2. **Key module** references (recommended for multi-environment):
   - Configure key machine names in CDN settings (`s3.access_key_key`,
     `s3.secret_key_key`)
3. **State API** (set via admin settings form):
   - Stored in `key_value` table, not exportable with config sync
4. **Legacy config** (deprecated, removed in 2.0.0):
   - Plain text in `ffmpeg_media.cdn`

**Migration steps:**

1. Set credentials via environment variables or install the
   [Key](https://www.drupal.org/project/key) module and configure key
   references.
2. Clear the legacy config values:
   `drush config:set ffmpeg_media.cdn s3.access_key '' && drush config:set ffmpeg_media.cdn s3.secret_key ''`
3. Verify CDN operations continue working.
4. Export config (`drush config:export`) to remove plain text credentials from
   your config sync directory.

## Hardware Detection

`HardwareProbe` reads GPU information from `/sys/class/drm/` (system-only paths). The `@file_get_contents()` calls are safe as they only read kernel-provided device files, not user-supplied paths.

## Permissions

The base module defines `administer ffmpeg media` and `convert media` for administrative and conversion operations. Queue processing is handled via Drupal's cron system with standard access controls.

## Health Probe Access Control

Liveness/readiness endpoints are route-public for orchestrator compatibility, but
runtime access can be hardened:

- `health_probe_token` enables shared-secret validation.
- `health_probe_token_required` enforces deny-by-default (`403`) when no valid
  token is provided.
- Token transport: header `X-FFmpeg-Health-Token` only. Query parameters are
  never accepted to prevent credential leakage into URLs, reverse-proxy logs,
  and analytics.

Default install: `health_probe_token_required` is `true` (secure-by-default).
A probe token is auto-generated during module installation. To disable token
enforcement during development, set `health_probe_token_required: false` in
configuration. In production, ensure a strong token is configured via
`FFMPEG_HEALTH_PROBE_TOKEN` environment variable or the admin settings form.

Production hardening (recommended):

- `APP_ENV=prod` or `APP_ENV=production` automatically enables
  `health_probe_token_required` at runtime.
- `FFMPEG_HEALTH_PROBE_TOKEN` provides secret via environment variable.
- `FFMPEG_HEALTH_PROBE_REQUIRED` can override requirement explicitly.
- The admin settings form exposes token rotation and
  `health_probe_token_required`, but the token itself is stored in the State
  API rather than exportable config.

### Token Rotation

To rotate the health probe token in production:

1. Generate a new token: `openssl rand -hex 32`
2. Set the new token via environment variable: `FFMPEG_HEALTH_PROBE_TOKEN=<new-token>`
3. Update all monitoring/orchestrator systems to use the new token in the `X-FFmpeg-Health-Token` header
4. Restart the application (or clear Drupal caches if using config override)
5. Verify probes respond with `200`: `curl -H "X-FFmpeg-Health-Token: <new-token>" https://example.com/api/ffmpeg-media/health/liveness`

The old token becomes invalid immediately after the environment variable is updated and caches are cleared. There is no grace period — coordinate the switch with your monitoring infrastructure.

### Multi-Server / Clustered Deployments

The health probe token is stored in Drupal's **State API** (`\Drupal\Core\State\StateInterface`), which is backed by the `key_value` database table. In single-server deployments this works transparently. In clustered or multi-node deployments, be aware of these characteristics:

- **State API is node-local** when each node has its own database. Tokens generated on one node (e.g., during `drush deploy` or module install) will not propagate to other nodes automatically.
- **Environment variable takes precedence.** `FFMPEG_HEALTH_PROBE_TOKEN` overrides the State API value at runtime (see `HealthProbeAccessManager::getProbeAccessSettings()`). In clustered environments, this is the **recommended** approach — set identical ENV values on all nodes.
- **Token rotation must be coordinated.** When rotating tokens, all nodes, load balancers, and monitoring/orchestrator systems must receive the new token simultaneously. There is no grace period; the old token becomes invalid immediately.
- **Shared database clusters** (e.g., Aurora, Galera) share State API automatically — no special action needed beyond normal token rotation.

**Recommended approaches for clustered deployments:**

1. **Centralized secret management** — Use HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, or Kubernetes Secrets to inject `FFMPEG_HEALTH_PROBE_TOKEN` into all nodes.
2. **Configuration management** — If all nodes share the same deployment pipeline, set the ENV variable in the orchestration layer (Kubernetes Deployment spec, ECS Task Definition, Docker Compose environment).
3. **Shared database** — If all nodes share one database, State API is automatically consistent. Use the admin settings form or `drush state:set ffmpeg_media.health_probe_token <value>` on any node.

## Queue Deduplication

`QueueManager::queueForConversion()` uses an atomic INSERT with `IntegrityConstraintViolationException` catch to prevent TOCTOU race conditions in media deduplication. The `media_id` primary key constraint in the `ffmpeg_media_queued` table ensures atomicity.

## Status Report Integration

`RequirementsHook` reports FFmpeg/ffprobe availability, hardware acceleration status, and queue health on Drupal's status report page (`/admin/reports/status`).

The UI submodule (`ffmpeg_media_ui`) adds 4 granular permissions:

| Permission | Scope |
|------------|-------|
| `access ffmpeg media ui` | View dashboard, status, progress |
| `transcode media` | Queue conversions, select posters |
| `manage media outputs` | Batch operations, delete outputs |
| `manage ffmpeg queue` | Queue editor: list, reorder, pause, resume, remove |

## API/AJAX/SSE Response Hardening

Machine endpoints are centrally hardened by
`ApiResponseSecurityHeadersSubscriber`:

- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer`
- `Content-Security-Policy: default-src 'none'; ...`
- `Cross-Origin-Resource-Policy: same-origin`
- no-store cache directives (`Cache-Control`, `Pragma`, `Expires`)

Validation:

- E2E coverage in `tests/e2e/api.spec.ts`
- automated smoke run: `npm run security:smoke`
- when probes are token-protected, pass `FFMPEG_HEALTH_PROBE_TOKEN` so the
  smoke run exercises the authenticated probe path instead of the expected
  `403` fallback
