# Securing Theme Forms

https://dev.wisecp.com/es/securing-theme-forms

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; `{csrf}` and `{captcha}` exist in both.
- An operation to receive the submit ([Operations](https://dev.wisecp.com/en/operations)).
- Thresholds are the operator's, in `coremio/configuration/options.php`. Never hard-code one.

## Structure

### The Five Layers

| Layer | What it stops | What 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

| Piece | Theme side | Server side |
| --- | --- | --- |
| Token | `{csrf form='contact-form'}` prints a hidden `token` input. | Verified with the same key. |
| Challenge | `{captcha area='contact-form'}` prints the provider's box, or nothing. | Read by the captcha helper; the theme never names it. |
| Request header | The fetch call sends `X-Requested-With`. | Required unless the third argument says otherwise. |
| Thresholds | Nothing; 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.

```smarty
<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.

```php
// 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.

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

| Call | What it emits | When it emits nothing |
| --- | --- | --- |
| `{csrf form='<key>'}` | 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. |
| `{captcha area='<area>' tray='<id>'}` | 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*.

```php
// 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;
```

```php
// 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
```

```php
// 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
```

```php
// 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.

```php
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.

```php
// 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.

## Related Articles

- [Login and Registration](https://dev.wisecp.com/en/login-and-registration)
- [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters)
- [Operations](https://dev.wisecp.com/en/operations)
- [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input)
- [Writing a Captcha Module](https://dev.wisecp.com/en/writing-a-captcha-module)
