Security Practices

6 views Markdown

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 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

LayerWhat it stopsEngaged by
Input filteringInjection and markup smuggled through a fieldYour code, one call per field, with the type that matches the field
Output encodingStored values executing when they are printed backYour code in admin templates and JSON. Website templates escape automatically
Privilege checkA signed-in operator doing something their role does not allowThe operation wrapper, from the operation's own declaration. Your code when a fallback hook answers instead
API scopeA valid credential reaching an endpoint it was not grantedThe kernel, unless the route sets the auth-only flag, in which case any valid key passes
Owner scopeOne client reading another client's rowsYour code, in every query, from the injected owner and never from the request body
Form guardsCross-site submission, flooding, bots and spam contentYour 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

coremio/classes/Filter.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 = '');
TypeWhat survives itUse it for
passwordEverything. A deliberate pass-through that returns the argument untouchedSecrets of every kind. Any other type deletes the characters that make them strong
hclearText with tags strippedFree text with no other shape: a note, a subject line, a name
rnumbersAn integerRecord identifiers, counts, anything you are about to put in a query
numbersDigits and the hyphen, as a stringPhone numbers and reference codes, where the leading zero matters
amount, rateDigits with separators, and a float respectivelyMoney and percentages. Never the plain text filter, which keeps a stray letter
emailThe address character set onlyAddresses, and then a format check on top: the filter removes, it does not validate
ipAddress charactersAddresses and ranges. Follow it with a real parse before you compare anything
routeLetters, digits, hyphen, underscore and dot, with parent traversal removed firstAnything that becomes part of a path or a route key
letters_numbersLetters and digits, plus whatever the third argument allowsIdentifiers you control the shape of: a database name, a module key, a slug
domainAlphanumerics, dots and hyphens, lowercased. It does not validate: not a domain!!! comes back as notadomainNames, 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

signatures
// 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.

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

signatures
// 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.

the template side
<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>
the handler side
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.

owner scope and file serving
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']);
}
resources/uploads/acme-notes/.htaccess
# 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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.