Securing Theme Forms

2 views Markdown

A public form is not protected by the framework. The template prints the guards and the handler enforces them.

Overview

No middleware shields a theme form. You write the pair: the view prints a token and a challenge box, the operation checks them. The form key joins the halves.

Five layers stack on one request. Four judge who submits, the fifth judges what was submitted.

Prerequisites

  • A Smarty or Twig theme; and exist in both.
  • An operation to receive the submit (Operations).
  • Thresholds are the operator's, in coremio/configuration/options.php. Never hard-code one.

Structure

The Five Layers

LayerWhat it stopsWhat leaving it out opens
CSRF Submits that did not come from your own form. Any page can post to your endpoint with a signed-in visitor's session.
Process restriction Repetition from one address, with a timed hard block. One address can hold the endpoint open indefinitely.
Bot shield Automation, by demanding a challenge past a threshold. An automated client never meets a challenge; the form becomes an oracle.
Captcha (static) Every submit on an area the operator locked. The closed area stays open, and the panel shows the setting as on.
Spam guard Banned words, disposable mailboxes, reputation lists. No content rule runs, so the blocked list stays empty and looks healthy.

Each layer is one call, and that call is the proof.

Validation::verify_csrf_token() The token layer. A false result must end the request.
ProcessRestriction::blocked() The hard block layer. Asks only; hit() counts the request at the end.
BotShield::triggered() The adaptive layer. True means this address must answer a challenge.
Captcha::enabled() The static layer. Reads the per-area switch; combined with the previous by OR.
Validation::spam_guard() The content layer. Returns the blocking reason and records the attempt.
The order is part of the protection

Token, hard block, challenge, content, then the work. Reading input earlier lets an attacker reach your parser.

Which Half Owns Which Piece

PieceTheme sideServer side
Token prints a hidden token input.Verified with the same key.
Challenge
prints the provider's box, or nothing.
Read by the captcha helper; the theme never names it.
Request headerThe fetch call sends X-Requested-With.Required unless the third argument says otherwise.
ThresholdsNothing; the theme reads no limit.Read from the operator's configuration.

Walkthrough

1. Mark Up the Form

  1. Pick one key string and use it everywhere: token key, captcha area, throttle action.
  2. Print the token inside the form element.
  3. Print the captcha next to the button; it shows nothing when that area is off.
views/content/contact.tpl
<form action="{link route='contact'}" method="post" novalidate data-contact-form>

    <input type="text"  class="form-control" name="name"  required>
    <input type="email" class="form-control" name="email" required>
    <textarea class="form-control" name="message" rows="6" required></textarea>

    {* Same string as the handler's verify key. Emits <input type="hidden" name="token">. *}
    {csrf form='contact-form'}

    <div class="d-flex flex-wrap align-items-center gap-3 border-top pt-3 mt-4">
        {* Renders '' when the operator has captcha off for this area, so the row still lays out. *}
        {captcha area='contact-form'}
        <button class="btn btn-primary ms-sm-auto" type="submit">{lang key='website/contact/send-button'}</button>
    </div>
</form>

2. Guard the Handler

  1. Verify the token before you touch the request.
  2. Ask whether this address is hard blocked, and stop if it is.
  3. Decide whether a challenge is required: the area is locked or the shield tripped.
  4. Read and validate the inputs, then run the spam guard on them.
  5. Do the work, then close the window: advance the limiter, update the shield counter.
the fixed order
// 1. Token. Before anything is read from the request.
if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "contact-form"))
    throw new \Exception(Language::g("needs/csrf-failed"));

// 2. Hard block. This address already crossed the limit and is serving a timeout.
if (\ProcessRestriction::blocked("contact-form"))
    throw new \Exception(Language::gc("website/contact/rate-limited"));

// 3. Challenge. Operator-locked area OR this address tripped the shield.
$needCaptcha = \Captcha::enabled("contact-form") || \BotShield::triggered("contact-form");
if ($needCaptcha && !(new \Captcha())->check()) {
    \BotShield::record("contact-form");
    return $operation->output([
        "status"  => "captcha_required",
        "message" => Language::gc("website/contact/captcha-required"),
    ]);
}

// 4. Content and sender, after validation, before the write.
if (\Validation::spam_guard($full_name, $message, $email, $phone, $ip) !== '')
    throw new \Exception(Language::g("needs/spam-blocked"));

// 5. ... the actual work ...

// 6. Close the window.
\ProcessRestriction::hit("contact-form");
if ($needCaptcha) \BotShield::clear("contact-form");
else              \BotShield::record("contact-form");

3. Send the Request

  1. Build the body from the form element; FormData carries the token and the answer.
  2. Send the AJAX header, or the token check refuses.
  3. Handle the third outcome: captcha_required is a question, not a refusal.
  4. Refresh the challenge in the finally branch. The answer is single use.
assets/js/contact.js
var fd = new FormData(form);          // token + captcha answer are named inputs inside the form

fetch(endpoint, {
    method: 'POST',
    body: fd,
    headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
    .then(function (r) { return r.json(); })
    .then(function (res) {
        if (res.status === 'captcha_required') {
            captchaRequired = true;               // remember it; the next empty submit is stopped locally
            captchaMsg      = res.message || captchaMsg;
            revealCaptcha();
            setAlert(captchaMsg, 'warning');
            return;
        }
        if (res.status !== 'successful') { setAlert(res.message, 'danger'); return; }
        captchaRequired = false;
        showDoneStep(res);
    })
    .finally(function () {
        if (typeof window.wcpCaptchaRefresh === 'function') window.wcpCaptchaRefresh();
    });

Reference

Template Functions

CallWhat it emitsWhen it emits nothing
A hidden token input: an HMAC of the key against a per-session secret. Never. An empty key is still a key, shared by every form using it.
The provider's box in the theme slot; a tray when tray is given. When captcha is off and the shield is not armed. Design the row without it.

In Twig the options have an order: captcha(area, tray, class, force) and csrf(form). Skipping one means naming it.

Helper Signatures

Read the argument order carefully: the token functions take the key second.

Validation
// coremio/classes/Validation.php

// $input = true returns the ready <input type="hidden" name="token">; false returns the bare token.
public static function get_csrf_token($form_index = '', $input = true);

// The KEY IS THE SECOND ARGUMENT. $nonAjax = true drops the X-Requested-With requirement.
public static function verify_csrf_token($incoming_data = '', $form_index = '', $nonAjax = false);

// '' means clean. A non-empty string is the operator-facing reason, already written to the blocked list.
public static function spam_guard(string $subject = '', string $message = '', string $email = '', string $phone = '', string $ip = '', string $domain = ''): string;
ProcessRestriction
// coremio/helpers/processrestriction.php
// $ip = null resolves the caller's address itself, proxy and CDN aware. Pass one only when
// you are judging an address other than the current visitor's.

public static function blocked(string $action, ?string $ip = null): bool;   // serving a timeout right now
public static function hit(string $action, ?string $ip = null): bool;       // count one; true = now blocked
public static function clear(string $action, ?string $ip = null): void;     // reset counter and block
BotShield
// coremio/helpers/botshield.php

public static function active(string $action): bool;                        // armed for this action at all
public static function triggered(string $action, ?string $ip = null): bool; // this address needs a challenge
public static function record(string $action, ?string $ip = null): void;    // count one uncontested attempt
public static function clear(string $action, ?string $ip = null): void;     // a challenge was solved
Captcha
// coremio/helpers/captcha.php

public static function enabled(string $area = ''): bool;                    // operator switched this area on
public static function widget(string $area = '', array $opts = []): string; // what {captcha} calls
public function check(): bool;                                              // instance method: (new Captcha())->check()

Widget Options

tray The id of a collapse tray to hold the box. Sanitised to letters, digits, underscore, hyphen.
class Utility classes for the slot wrapper.
force Always visible, ignoring the per-area switch and the shield. A decision, not a default.

The Three Response Shapes

successful The work happened; swap the form for a confirmation step.
captcha_required The server is asking, not refusing. Reveal the box and keep the typed values.
error A thrown exception as JSON. Already translated; the spam reason is not in it.

Example

The contact form, both halves. The key contact-form repeats in view and handler, so one grep proves the pair.

the operation that receives it
public function submit(Operation $operation): bool
{
    $operation->demo();

    if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "contact-form"))
        throw new \Exception(Language::g("needs/csrf-failed"));

    if (\ProcessRestriction::blocked("contact-form"))
        throw new \Exception(Language::gc("website/contact/rate-limited"));

    $needCaptcha = \Captcha::enabled("contact-form") || \BotShield::triggered("contact-form");
    if ($needCaptcha && !(new \Captcha())->check()) {
        \BotShield::record("contact-form");
        return $operation->output([
            "status"  => "captcha_required",
            "message" => Language::gc("website/contact/captcha-required"),
        ]);
    }

    $full_name = trim((string) Filter::init("POST/name", "hclear"));
    $email     = trim((string) Filter::init("POST/email", "email"));
    $phone     = trim((string) Filter::init("POST/phone", "numbers"));
    $message   = trim((string) Filter::init("POST/message", "hclear"));
    $ip        = \UserManager::GetIP();

    if (\Validation::isEmpty($full_name))
        throw new \Exception(Language::gc("website/contact/error-name"));
    if (\Validation::isEmpty($email) || !\Validation::isEmail($email))
        throw new \Exception(Language::gc("website/contact/error-email"));
    if (\Validation::isEmpty($message) || mb_strlen($message) < 5)
        throw new \Exception(Language::gc("website/contact/error-message"));

    // Content rules run on the values that are about to be stored, never on the raw request.
    if (\Validation::spam_guard($full_name, $message, $email, $phone, $ip) !== '')
        throw new \Exception(Language::g("needs/spam-blocked"));

    $message_id = $this->model->add([
        'full_name' => $full_name,
        'email'     => $email,
        'phone'     => $phone,
        'message'   => $message,
        'ip'        => $ip,
        'cdate'     => \DateManager::Now(),
    ]);

    \ProcessRestriction::hit("contact-form");
    if ($needCaptcha) \BotShield::clear("contact-form");
    else              \BotShield::record("contact-form");

    return $operation->output([
        "status"  => "successful",
        "message" => Language::gc("website/contact/success"),
        "email"   => $email,
    ]);
}

Two extension points: a veto hook before the write, an event hook after.

extension points
// Any listener returning a non-empty string refuses the submission with that message.
foreach (\Hook::run('gate:client.contact_submit', $full_name, $email, $phone, $message, $ip) as $veto)
    if (is_string($veto) && $veto !== '') throw new \Exception($veto);

// After the write. Return values are ignored; this is an announcement, not a decision.
\Hook::run('action:client.contact_submitted', $message_id, $full_name, $email, $phone, $message, $ip);
gate:client.contact_submit Runs after validation, before the write. A non-empty string is a refusal.
action:client.contact_submitted Runs after the row exists, with its id first. The return is ignored.

Pitfalls

A mistyped key rejects every submit, forever

The token is an HMAC of the key. Print one key, verify another, and the visitor sees an expired session. No warning, no log entry.

Without the AJAX header the token check refuses by design

Verification demands X-Requested-With unless the third argument disables it. A missing header looks like a wrong key.

Gate the client on the server's answer, not on whether the box is visible

A tray can open because the visitor started typing. Keep a flag that only captcha_required sets.

Do not reveal the results panel in the finally branch

Opening the results container after every request leaks the placeholder. Open it on success only.

Four bot layers do not imply the fifth

The throttling layers judge an address, not content. A perfect token still stores banned words until the spam guard runs.

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.