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;
andexist 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
| 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.
hit() counts the request at the end.
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 | 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 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
- Pick one key string and use it everywhere: token key, captcha area, throttle action.
- Print the token inside the form element.
- Print the captcha next to the button; it shows nothing when that area is off.
<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
- Verify the token before you touch the request.
- Ask whether this address is hard blocked, and stop if it is.
- Decide whether a challenge is required: the area is locked or the shield tripped.
- Read and validate the inputs, then run the spam guard on them.
- Do the work, then close the window: advance the limiter, update the shield counter.
// 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
- Build the body from the form element;
FormDatacarries the token and the answer. - Send the AJAX header, or the token check refuses.
- Handle the third outcome:
captcha_requiredis a question, not a refusal. - Refresh the challenge in the finally branch. The answer is single use.
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 |
|---|---|---|
|
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.
// 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;
// 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
// 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
// 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
The Three Response Shapes
Example
The contact form, both halves. The key contact-form repeats in view and handler, so one grep proves the pair.
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.
// 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);
Pitfalls
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.
Verification demands X-Requested-With unless the third argument disables it. A missing header looks like a wrong key.
A tray can open because the visitor started typing. Keep a flag that only captcha_required sets.
Opening the results container after every request leaks the placeholder. Open it on success only.
The throttling layers judge an address, not content. A perfect token still stores banned words until the spam guard runs.
Related Articles
Vielen Dank für Ihre Rückmeldung!
Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.