Files
XC_VM/docs/en/guides/input-validation.md
T
Divarion_D c982bb2f1e docs: audit dev docs for source drift, split oversized pages, add core-wiring
Verify every dev-doc claim against src/ and fix factual drift: wrong method
signatures/return types, wrong enum casing (BootContext cases are PascalCase),
stale paths (M3u parsers are Composer deps under vendor/, MobileDetect is
mobiledetect/mobiledetectlib v4.9.0 \Detection\MobileDetect, NotFoundException
lives in XcVm\Core\Container\Psr), a fictional `stream:check` command/class,
reversed migration-failure semantics ([FAIL] = not recorded, retried),
inverted isStreamRunning/isStreamAlive descriptions, findProcessPIDs ANY-not-ALL,
acquireCronLock has no shutdown callback, and nonexistent make targets.

Split oversized pages and fix nav + cross-links:
- modules.md -> module-authoring / module-lifecycle / module-extension-points
- cli-tools.md -> cli-tools + database-migrations
- streaming-subsystem.md -> + streaming-diagnostics
- geoip-and-device-detection.md -> geoip-isp-and-geo-routing + device-detection-and-stb-locking

Add development/core-wiring.md: how the core assembles itself at boot
(container population, ServiceContainer reference, bootAll orchestration,
CLI command auto-discovery, end-to-end Admin/CLI boot walkthroughs).

Only docs/en + mkdocs.yml touched; docs/ru is regenerated before release.
2026-08-26 22:42:43 +03:00

321 lines
12 KiB
Markdown

# Input Validation and Sanitization
XC_VM uses a two-layer defense for incoming request data. First, a **global sanitization** pass strips dangerous content from all PHP superglobals during bootstrap, before any application code runs. Second, an **action-level validation** layer checks that required fields are present before business logic executes.
Both layers are implemented in `src/Core/Validation/InputValidator.php`.
---
## Global Sanitization Flow
Sanitization runs automatically during bootstrap. When `LegacyInitializer::initCore()` is called (in `src/Core/Init/LegacyInitializer.php`), it performs the following steps before any controller or service code:
```
LegacyInitializer::initCore()
|
+-- InputValidator::cleanGlobals($_GET)
+-- InputValidator::cleanGlobals($_POST)
+-- InputValidator::cleanGlobals($_SESSION)
+-- InputValidator::cleanGlobals($_COOKIE)
|
+-- $input = InputValidator::parseIncomingRecursively($_GET)
+-- RequestManager::set(InputValidator::parseIncomingRecursively($_POST, $input))
```
After this sequence, all raw superglobals have been sanitized in-place, and the merged/cleaned GET+POST data is available through `RequestManager`.
The streaming context (`LegacyInitializer::initStreaming()`) performs the same sanitization sequence using the `Request` class, which provides equivalent methods for the streaming bootstrap path.
### cleanGlobals(&$rData, $rIteration = 0)
Recursively walks the given superglobal array and removes dangerous content. Applied to `$_GET`, `$_POST`, `$_SESSION`, and `$_COOKIE`.
Strips the following from every scalar value:
| Threat | Pattern removed | Replacement |
| --- | --- | --- |
| Null byte injection | `\0` (chr 0) | removed |
| Path traversal | `../` | `../` (HTML-encoded) |
| RTL override (UI spoofing) | `‮` | removed |
Recursion is capped at 10 levels to prevent stack exhaustion from deeply nested input.
### parseIncomingRecursively(&$rData, $rInput, $rIteration = 0)
Recursively walks GET and POST data, applying key and value sanitization to every leaf. For arrays, it recurses up to 20 levels deep. For scalar values, it applies `parseCleanKey()` to the key and `parseCleanValue()` to the value.
The merged result (GET first, then POST overlaid) is stored in `RequestManager` for use throughout the request lifecycle.
### parseCleanKey($rKey)
Sanitizes array keys to prevent injection through key names:
1. URL-decodes and HTML-escapes the key (`htmlspecialchars(urldecode(...))`)
2. Removes double-dot sequences (`..` -> `''`)
3. Strips `__dunder__`-style markers via regex
4. Validates against allowed character set: word characters, dots, hyphens, underscores
### parseCleanValue($rValue)
Sanitizes scalar values with multiple passes:
| Step | What it does |
| --- | --- |
| Unescape | `stripslashes()` and restore ` ` to space |
| Normalize newlines | Convert `\r\n`, `\n\r`, `\r` to `\n` |
| HTML comment protection | `<!--` becomes `&#60;&#33;--`, `-->` becomes `--&#62;` |
| Script tag neutralization | `<script` (case-insensitive) becomes `&#60;script` |
| Entity normalization | Fix double-encoded entities and malformed numeric entities |
| Trim | Leading/trailing whitespace removed |
---
## Action-Level Validation
### validate()
```php
InputValidator::validate(string $rAction, array $rData): bool
```
Checks that the minimum required fields are present for a given action. Returns `true` if data is acceptable, `false` if required fields are missing or malformed. Controllers should call this before forwarding data to service/repository layers.
```php
if (!InputValidator::validate($action, $data)) {
// reject with validation error
}
```
### validateOrFail()
```php
InputValidator::validateOrFail(string $rAction, array $rData): ?array
```
Convenience wrapper around `validate()`. Returns `null` if the data is valid, or an error array if validation fails:
```php
$error = InputValidator::validateOrFail($action, $data);
if ($error !== null) {
// $error = ['status' => STATUS_INVALID_INPUT, 'data' => $data]
return $error;
}
```
### confirmIDs($ids)
```php
InputValidator::confirmIDs($ids) // untyped params/return; yields a filtered array of positive int IDs
```
Filters an array to contain only positive integer IDs. Any value where `intval($id) <= 0` is dropped. Used extensively across the codebase (30+ call sites) wherever user-supplied ID lists need to be sanitized before database queries.
```php
$safeIds = InputValidator::confirmIDs($userSuppliedIds);
// [1, 42, 7] -- negative, zero, and non-numeric values removed
```
---
## Validation Action Reference
The `validate()` method uses a `switch` statement over the action name. Actions are grouped below by functional area.
### Content Management
#### Streams and Channels
| Action | Required fields | Notes |
| --- | --- | --- |
| `processStream` | `stream_display_name` OR `review` flag OR `$_FILES['m3u_file']` | Any one of the three satisfies validation |
| `processChannel` | `stream_display_name` OR `review` flag OR `$_FILES['m3u_file']` | Same rules as processStream |
| `processRadio` | `stream_display_name` OR `review` flag OR `$_FILES['m3u_file']` | Same rules as processStream |
#### Movies / VOD
| Action | Required fields | Notes |
| --- | --- | --- |
| `processMovie` | `stream_display_name` OR `review` flag OR `$_FILES['m3u_file']` | Same rules as processStream |
#### Series and Episodes
| Action | Required fields | Notes |
| --- | --- | --- |
| `processSeries` | `title` | Series name is required |
| `processEpisode` | `series` (non-empty) AND `season_num` (numeric) AND (`multi` flag OR `episode` numeric) | Complex multi-path validation |
### Organization
#### Bouquets
| Action | Required fields | Notes |
| --- | --- | --- |
| `processBouquet` | `bouquet_name` | Required scalar |
| `reorderBouquet` | `stream_order_array` | Must decode to a JSON array |
| `sortBouquets` | `bouquet_order_array` | Must decode to a JSON array |
#### Categories
| Action | Required fields | Notes |
| --- | --- | --- |
| `processCategory` | `category_name`, `category_type` | Both required |
| `orderCategories` | `categories` | Must decode to a JSON array |
#### Groups and Codes
| Action | Required fields | Notes |
| --- | --- | --- |
| `processGroup` | `group_name` | Required scalar |
| `processGroupLegacy` | `group_name` | Same as processGroup |
| `processCode` | `code` | Required scalar |
| `processPackage` | `package_name` | Required scalar |
### EPG
| Action | Required fields | Notes |
| --- | --- | --- |
| `processEPG` | `epg_name`, `epg_file` | Both required |
### Devices and Lines
| Action | Required fields | Notes |
| --- | --- | --- |
| `processMAG` | `mac` | MAC address required |
| `processEnigma` | `mac` | MAC address required |
| `setChannelOrder` | `stream_order_array` | Must decode to a JSON array |
### Profiles
| Action | Required fields | Notes |
| --- | --- | --- |
| `processProfile` | `profile_name` | Required scalar |
### Providers
| Action | Required fields | Notes |
| --- | --- | --- |
| `processProvider` | `ip`, `port`, `username`, `password`, `name` | All five required |
| `processISP` | `isp` | Required scalar |
| `processUA` | `user_agent` | Required scalar |
### Security
| Action | Required fields | Notes |
| --- | --- | --- |
| `blockIP` | `ip` | IP address required |
| `processRTMPIP` | `ip` | IP address required |
### Server Management
| Action | Required fields | Notes |
| --- | --- | --- |
| `processServer` | `server_name`, `server_ip` | Both required |
| `processProxy` | `server_name`, `server_ip` | Same as processServer |
| `installServer` | `ssh_port`, `root_password` | Both required |
| `moveStreams` | `content_type`, `source_server`, `replacement_server` | All three required |
| `replaceDNS` | `old_dns`, `new_dns` | Both required |
| `orderServers` | `server_order` | Must decode to a JSON array |
### Recordings and Watch Folders
| Action | Required fields | Notes |
| --- | --- | --- |
| `scheduleRecording` | `title`, `source_id` | Both required |
| `processWatchFolder` | `folder_type`, `selected_path`, `server_id` | All three required |
### Mass Operations (JSON Array Payloads)
All mass operations require a JSON-encoded array in the specified field. The field must decode to a valid PHP array.
| Action | JSON field |
| --- | --- |
| `massEditEpisodes` | `streams` |
| `massEditMovies` | `streams` |
| `massEditRadios` | `streams` |
| `massEditStreams` | `streams` |
| `massEditChannels` | `streams` |
| `massDeleteStreams` | `streams` |
| `massEditSeries` | `series` |
| `massDeleteSeries` | `series` |
| `massEditLines` | `users_selected` |
| `massEditUsers` | `users_selected` |
| `massEditMags` | `devices_selected` |
| `massEditEnigmas` | `devices_selected` |
| `massDeleteMovies` | `movies` |
| `massDeleteLines` | `lines` |
| `massDeleteUsers` | `users` |
| `massDeleteStations` | `radios` |
| `massDeleteMags` | `mags` |
| `massDeleteEnigmas` | `enigmas` |
| `massDeleteEpisodes` | `episodes` |
---
## Default Fallthrough Behavior
Actions not explicitly listed in the `switch` statement fall through to `return true`, meaning they always pass validation. This is intentional -- these actions either have no required fields at the gate level, or perform their own validation deeper in the business logic layer.
**Explicitly pass-through actions** (listed in the switch with `return true`):
- `processUser`
- `processLine`
- `processHMAC`
- `editAdminProfile`
- `editSettings`
- `editBackupSettings`
- `editCacheCron`
- `editPlexSettings`
- `editWatchSettings`
- `processPlexSync`
- `processLogin`
- `submitTicket`
**Implicitly pass-through actions** (not in the switch at all, caught by the default `return true`):
Any action string not matching a `case` will also return `true`. If a new action needs input gating, a case must be added explicitly.
---
## Validation Patterns Used
The `validate()` method uses a small set of patterns consistently:
| Pattern | Purpose | Example |
| --- | --- | --- |
| `!empty($rData['field'])` | Required scalar field (non-null, non-empty, non-zero) | `!empty($rData['bouquet_name'])` |
| `is_numeric($rData['field'] ?? null)` | Numeric validation with null-safe fallback | `is_numeric($rData['season_num'] ?? null)` |
| `is_array(json_decode($rData['field'] ?? '', true))` | JSON string that must decode to an array | `is_array(json_decode($rData['streams'] ?? '', true))` |
| `isset($rData['field'])` | Field presence check (value can be empty/falsy) | `isset($rData['review'])` |
| `isset($_FILES['field'])` | File upload presence check | `isset($_FILES['m3u_file'])` |
| OR conditions | Multi-path validation (any one path satisfies) | `!empty($rData['stream_display_name']) \|\| isset($rData['review']) \|\| isset($_FILES['m3u_file'])` |
---
## Adding Validation for a New Action
Add a `case` to the `switch` in `src/Core/Validation/InputValidator.php`:
```php
case 'myNewAction':
return !empty($rData['required_field'])
&& is_numeric($rData['numeric_field'] ?? null);
```
Guidelines:
- Validate only the minimum required inputs at this layer. Keep domain-specific rules (format validation, business constraints, uniqueness checks) in the service layer.
- Use `!empty()` for required scalars, `is_numeric()` for numeric fields, and `is_array(json_decode(..., true))` for JSON array payloads.
- For actions that accept file uploads as an alternative to form fields, include `isset($_FILES['field'])` as an OR condition.
- If the action needs no gate-level validation, add it to the explicit pass-through block with `return true` so future maintainers know the omission is intentional rather than accidental.
---
## Related files
| File | Purpose |
| --- | --- |
| `src/Core/Validation/InputValidator.php` | All sanitization and validation logic |
| `src/Core/Init/LegacyInitializer.php` | Bootstrap caller that triggers sanitization via `initCore()` |
| `src/Core/Http/RequestManager.php` | Stores the sanitized, merged GET+POST data |
| `src/Public/Controllers/` | Controllers that call `validate()` / `validateOrFail()` before business logic |