# Security Practices

https://dev.wisecp.com/es/security-practices

The security layers this platform provides, and the call that engages each one. Alongside them, three mistakes that have shipped. A permanently open guard, a filter that destroys the value it protects, an upload served as executable markup.

## Overview

Every layer below exists in this codebase, is engaged by one named call, and is enforced at one named place. None is automatic. A controller does not filter its own input, and a public form is not protected until it asks.

Third-party code goes wrong in two of them. Input filtering is per field and per type. Ownership on the client API is per query, not per request.

## Prerequisites

- [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) for the full filter vocabulary. This article covers the consequences of choosing wrong.
- A surface to secure, and knowledge of who reaches it. An admin operation, a public form, a client endpoint and a module endpoint engage different layers.
- The panel's Security settings open in another tab. Every threshold below is a configuration key. A limit hardcoded into your module ignores the operator's setting.

## Structure

### The Layers

| Layer | What it stops | Engaged by |
| --- | --- | --- |
| Input filtering | Injection and markup smuggled through a field | **Your code**, one call per field, with the type that matches the field |
| Output encoding | Stored values executing when they are printed back | **Your code** in admin templates and JSON. Website templates escape automatically |
| Privilege check | A signed-in operator doing something their role does not allow | The operation wrapper, from the operation's own declaration. **Your code** when a fallback hook answers instead |
| API scope | A valid credential reaching an endpoint it was not granted | The kernel, unless the route sets the auth-only flag, in which case any valid key passes |
| Owner scope | One client reading another client's rows | **Your code**, in every query, from the injected owner and never from the request body |
| Form guards | Cross-site submission, flooding, bots and spam content | **Your code**, four to five calls in a fixed order, plus two template tags |

## Walkthrough

### Read Input Safely

1. Never touch a superglobal. Read through the filter helper with a source prefix.
2. Pick the filter type from what the field *is*, not from habit.
3. Filtering is not validating. After the type filter removes what cannot belong, check that the rest is usable.
4. Read every array value with a default and cast it in the same expression. A missing key in a loop is also a log write on every iteration.
5. Write the presence test against `false`, never against `null`.

### Authorise the Caller

1. In a normal admin operation, declare the privilege list in the operation's own properties. The wrapper checks it before your method runs.
2. In an operation answered by the fallback hook, check the privilege yourself, first in the listener. The wrapper has already declined, so nothing of it applies. Not the privilege list, not the demo-mode guard, not the refusal of non-AJAX requests.
3. On a module API route, choose between a scope and the auth-only flag deliberately. Auth-only lets in any valid credential of the right audience. Sensible for the free surface, not for anything that writes.
4. On any client-facing endpoint, take the identity from the injected owner. An account identifier in the request body is the caller's suggestion, not an answer.

### Protect a Public Form

1. Print the token tag inside the form element, and the captcha tag where the form can require one.
2. Verify the token first, right after the demo guard. Verification expects the AJAX header by default. A form that posts normally passes the non-AJAX flag as the third argument.
3. Check the hard block, then the captcha requirement. The spam guard comes last, after the fields are read.
4. Count the request at the end, and clear or record the shield window depending on whether a captcha was solved.
5. Add your form to the spam guard's call list. A guest surface that skips it has the four bot layers and none of the content rules.

### Serve an Uploaded File

1. Stream it through the helper. Never write the content type header yourself from the detected type.
2. Deny direct access to the upload directory with a rule file.
3. Leave image directories meant to be shown inline alone.
4. Verify all three. The static path returns forbidden and the image path still works. The controller forces a download for markup and shows plain text inline.

## Reference

### Reading Input

```php
// $arg is "SOURCE/key" or "SOURCE/key/subkey"; sources: GET/ POST/ REQUEST/ FILES/ SERVER/
// $mod is the filter type; $special adds characters to the allowed set of some types.
public static function init($arg = NULL, $mod = false, $special = false);

// Raw access to one source. Nested keys with a slash. Absent key => false.
public static function GET($arg = '');
public static function POST($arg = '');
public static function REQUEST($arg = '');
public static function SERVER($arg = '');

// Allowed-tag strip, used by the "hclear" type and callable directly.
public static function html_clear($arg = NULL, $allow = '');
```

| Type | What survives it | Use it for |
| --- | --- | --- |
| `password` | **Everything.** A deliberate pass-through that returns the argument untouched | Secrets of every kind. Any other type deletes the characters that make them strong |
| `hclear` | Text with tags stripped | Free text with no other shape: a note, a subject line, a name |
| `rnumbers` | An integer | Record identifiers, counts, anything you are about to put in a query |
| `numbers` | Digits and the hyphen, as a string | Phone numbers and reference codes, where the leading zero matters |
| `amount`, `rate` | Digits with separators, and a float respectively | Money and percentages. Never the plain text filter, which keeps a stray letter |
| `email` | The address character set only | Addresses, and then a format check on top: the filter removes, it does not validate |
| `ip` | Address characters | Addresses and ranges. Follow it with a real parse before you compare anything |
| `route` | Letters, digits, hyphen, underscore and dot, with parent traversal removed first | Anything that becomes part of a path or a route key |
| `letters_numbers` | Letters and digits, plus whatever the third argument allows | Identifiers you control the shape of: a database name, a module key, a slug |
| `domain` | Alphanumerics, dots and hyphens, lowercased. It does **not** validate: `not a domain!!!` comes back as `notadomain` | Names, and then a real check on top, exactly like the address filter. It also ignores the third argument |

> **An absent key is false, so a guard written against null is always open**
> 
> Both the typed reader and the direct source readers return `false` for a missing key. They never return `null`. A not-equal-to-null condition is true on every request, so the branch behind it runs unconditionally. This has shipped. A mode flag guarded that way loaded a simulation layer on every visit. Nothing reached the server while the responses looked successful. Cast to string before comparing with an empty string.

### Authorising

```php
// coremio/helpers/admin.php - true when the signed-in operator holds the privilege.
public static function isPrivilege($privileges): bool;

// coremio/api/Auth/Scope.php - the credential's grants against the route's requirement.
public function __construct(array $granted);
public function allows(string $required): bool;

// coremio/api/Resources/Client/_ClientResource.php - the client the request acts for.
protected function owner(): int;
protected function assertOwned(mixed $row, string $message = 'Resource not found.'): array;
```

- **Admin::isPrivilege()**: Takes the same array a normal operation declares. There the wrapper calls it for you and refuses before your method runs. In a fallback-hook listener nothing calls it, so you do.
- **Scope::allows()**: Grants are `Group/Action` strings and match three ways. The exact string, the whole group with a trailing wildcard, or a single wildcard meaning everything. An empty requirement passes: an unset route scope is an open route.
- **owner()**: The acting client, injected by the kernel from the credential on an external call and from the input on an internal one. It throws rather than returning zero, so no query silently runs unscoped.
- **assertOwned()**: Turns both "does not exist" and "belongs to somebody else" into the same not-found answer. The distinction is itself information: leaking it lets a caller enumerate which identifiers are real.

### Guarding a Public Form

Five independent layers, in this order. The first four judge who is asking, the fifth what they sent.

| Order | Call | When it says no |
| --- | --- | --- |
| 1 | `Validation::verify_csrf_token($token, $key, $nonAjax = false)` | Refuse immediately, before any other work. The key must match the one the template printed |
| 2 | `ProcessRestriction::blocked($action)` | This address is inside a hard block window. Answer with the rate-limit message and stop |
| 3 | `Captcha::enabled($area)` or `BotShield::triggered($action)` | A captcha is required. Record the attempt and answer with the captcha-required status |
| 4 | `Validation::spam_guard($subject, $message, $email, $phone, $ip, $domain)` | Non-empty means blocked. The returned reason goes to the log, never to the visitor |
| 5 | `ProcessRestriction::hit($action)` plus shield record or clear | After the real work. Skipping it is why a limit that looks configured never fires |

```php
// coremio/classes/Validation.php
public static function get_csrf_token($form_index = '', $input = true);
public static function verify_csrf_token($incoming_data = '', $form_index = '', $nonAjax = false);
public static function spam_guard(string $subject = '', string $message = '', string $email = '',
                                  string $phone = '', string $ip = '', string $domain = ''): string;
public static function password_chars_error($password = ''): string;

// coremio/helpers/processrestriction.php - null ip means "the caller's", proxy aware.
public static function blocked(string $action, ?string $ip = NULL): bool;
public static function hit(string $action, ?string $ip = NULL): bool;
public static function clear(string $action, ?string $ip = NULL): void;

// coremio/helpers/botshield.php - the adaptive captcha, counted per address.
public static function active(string $action): bool;
public static function triggered(string $action, ?string $ip = NULL): bool;
public static function record(string $action, ?string $ip = NULL): void;
public static function clear(string $action, ?string $ip = NULL): void;

// coremio/helpers/captcha.php - the static setting, and the answer check.
public static function enabled(string $area = ''): bool;
public function check(): bool;

// coremio/classes/FraudModule.php - order time only. Empty string means clean.
public static function run_checks(array $params = []): string;
```

- **spam_guard() returns a reason, not a boolean**: An empty string is clean; anything else is the operator-facing reason, already written to the blocked list. Show the visitor the generic translated message instead. The reason names which rule fired, which is a tuning aid for an attacker.
- **The token key is a literal contract**: The string the template printed and the string the handler verifies must be identical. They are not derived from the route, so a mismatch is not an error you will see. Every submission fails verification, quietly.
- **Thresholds live in configuration**: Attempt counts, windows and block durations are settings the operator owns, in the options file the Security screens write. A number written into your module stopped working the moment the operator changed theirs.
- **Fraud checks are a separate layer**: They run at order creation, before anything is persisted, and they are not the spam guard. A module that throws is treated as a pass and logged, so a provider outage cannot take checkout down.

### Output and Files

```php
// coremio/classes/Utility.php - the JSON encoder every response goes through.
public static function jencode($string = '', $flags = 0): string|false;
public static function jdecode($string = '', $mode = false);

// coremio/classes/Utility.php - the only supported way to hand a stored file to a browser.
public static function stream_uploaded_file(string $diskPath, string $fileName, array $opt = []): void;
```

- **stream_uploaded_file()**: Serves inline only for a fixed inert set: portable documents, the four raster image types and plain text. Everything else is forced to a download, including markup and vector images. It carries a generic type, a no-sniff header and a sandbox policy. It strips line breaks and quotes from the file name, sends the content and exits.
- **The cache option is for content-addressed URLs only**: The optional third argument overrides the cache policy, for addresses that change when the file changes. A sensitive document must not pass it: the short private default is the point.
- **Utility::jencode()**: Used instead of the raw encoder so every response shares one flag set. That keeps non-Latin characters and slashes readable rather than escaped. Encoding is not escaping: a value printed into markup still needs escaping at the point of print.
- **htmlspecialchars()**: Admin templates are plain PHP with no automatic escaping, so anything you print there escapes at the point of print. Website templates escape by default, the opposite trap: a pre-encoded value stored there is encoded twice and shows the entities on screen.

## Example

A guest form end to end. The template prints the two tags, the handler applies the five layers in order.

```smarty
<form action="{link route='acme-request'}" method="post" data-results="#acme-out">
    <input type="text"  name="company">
    <input type="email" name="email">
    <input type="password" name="panel_password">
    <textarea name="note"></textarea>

    {* the key here and the key in the handler are one literal contract *}
    {csrf form='acme-request'}
    {captcha area='acme-request' tray='acme-captcha'}
</form>
```

```php
public function submit(\Operation $operation): bool
{
    // The wrapper hands every operation an Operation object. Open with the demo guard:
    // it throws before a write can happen on a demo system.
    $operation->demo();

    // 1. Token before any other guard. Third argument true for a non-AJAX post.
    if (!\Validation::verify_csrf_token((string) Filter::init('POST/token', 'hclear'), 'acme-request'))
        return $operation->output(['status' => 'error', 'message' => Language::g('needs/csrf-failed')]);

    // 2. Hard block window for this address.
    if (\ProcessRestriction::blocked('acme-request'))
        return $operation->output(['status' => 'error', 'message' => Language::gc('acme/too-many')]);

    // 3. Captcha: the operator's static setting, OR the adaptive shield having tripped.
    $needCaptcha = \Captcha::enabled('acme-request') || \BotShield::triggered('acme-request');

    if ($needCaptcha && !(new \Captcha())->check()) {
        \BotShield::record('acme-request');
        return $operation->output(['status' => 'captcha_required']);
    }

    // One call per field, and the type is chosen from what the field IS.
    // The secret uses the pass-through filter: any text filter would silently delete
    // exactly the punctuation that makes a password strong.
    $company  = Filter::init('POST/company', 'hclear');
    $email    = Filter::init('POST/email', 'email');
    $note     = Filter::init('POST/note', 'dtext');
    $password = Filter::init('POST/panel_password', 'password');

    // Filtering removed what cannot belong; validation decides whether the rest is usable.
    if (!filter_var($email, FILTER_VALIDATE_EMAIL))
        throw new Exception(Language::gc('acme/email-invalid'));

    if ($e = \Validation::password_chars_error($password)) throw new Exception($e);

    // 4. Content and sender rules, after the fields are read, before the real work.
    if (\Validation::spam_guard('', $note, $email, '', UserManager::GetIP()) !== '')
        throw new Exception(Language::g('needs/spam-blocked'));

    // ... the real work

    // 5. Count this request and settle the shield window.
    \ProcessRestriction::hit('acme-request');
    if ($needCaptcha) \BotShield::clear('acme-request');
    else              \BotShield::record('acme-request');

    return $operation->output(['status' => 'successful']);
}
```

The other two surfaces, shaped so the guard cannot be skipped.

```php
public function GetNote(array $body = []): array
{
    $id = (int) ($body['id'] ?? 0);

    // The owner comes from the credential. An account id in the body is a SUGGESTION
    // from the caller and is never used to select the account.
    $row = WDB::select('*')->from('acme_notes')
              ->where('id', '=', $id)
              ->where('owner_id', '=', $this->owner())
              ->build('assoc');

    // Missing and foreign both answer 404: which ids exist is itself information.
    return ['data' => $this->assertOwned($row)];
}

public function stream_note_file(int $id): void
{
    $row = $this->model->note($id);
    if ((int) ($row['owner_id'] ?? 0) !== $this->owner()) { http_response_code(404); exit; }

    // Never build these headers by hand from the detected type: an uploaded page
    // served inline runs on this origin, against the next operator who opens it.
    \Utility::stream_uploaded_file(ROOT_DIR . $row['path'], $row['name']);
}
```

```apache
# The ownership check above lives in PHP; the web server does not know about it and
# will serve the same bytes straight from the path. Reading the file from disk is a
# filesystem operation and is unaffected, only the direct HTTP route is closed.
# Both syntaxes, the way every private upload directory in the tree is already written:
# the 2.4 directive alone is an error on a server without mod_authz_core.
<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order allow,deny
    Deny from all
</IfModule>
```

## Pitfalls

> **A generic sanitiser breaks the secret fields first**
> 
> Text filters remove punctuation, and punctuation is what makes a password strong. Run one over a password field and the value is silently shortened. The account is created with something the customer never typed, and shows up later as a login that fails. Filter per field, and give secrets the pass-through type.

> **A web-reachable upload directory has no access control**
> 
> Every ownership check in your controller is bypassed by requesting the file's path directly. The server serves an uploaded page as a page. Both halves are required. The stream helper forces the type inert, the deny rule closes the static route.

> **Emptiness tests treat a stored zero as absent**
> 
> The emptiness test is true for the string zero. A setting the operator explicitly turned off reads as one that was never configured. The code falls through to its default. Read the key with a default and cast it in the same expression, then compare.

> **Never fetch a URL the caller supplied**
> 
> An outbound request to an address the caller chose reveals the origin behind a content network to whoever owns it. Client-facing endpoints take uploaded bytes instead. Fetch a URL only on an operator surface, or from a source the provider signed.

## Related Articles

- [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input)
- [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms)
- [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions)
- [The Client API](https://dev.wisecp.com/en/client-api-overview)
- [Operations](https://dev.wisecp.com/en/operations)
- [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work)
