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 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
- Never touch a superglobal. Read through the filter helper with a source prefix.
- Pick the filter type from what the field is, not from habit.
- Filtering is not validating. After the type filter removes what cannot belong, check that the rest is usable.
- 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.
- Write the presence test against
false, never againstnull.
Authorise the Caller
- In a normal admin operation, declare the privilege list in the operation's own properties. The wrapper checks it before your method runs.
- 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.
- 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.
- 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
- Print the token tag inside the form element, and the captcha tag where the form can require one.
- 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.
- Check the hard block, then the captcha requirement. The spam guard comes last, after the fields are read.
- Count the request at the end, and clear or record the shield window depending on whether a captcha was solved.
- 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
- Stream it through the helper. Never write the content type header yourself from the detected type.
- Deny direct access to the upload directory with a rule file.
- Leave image directories meant to be shown inline alone.
- 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
// $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 |
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
// 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;
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.
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 |
// 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;
Output and Files
// 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;
Example
A guest form end to end. The template prints the two tags, the handler applies the five layers in order.
<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>
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.
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']);
}
# 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
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.
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.
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.
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
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.