Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ forms with step validation. Save and resume. Pre-fill from the URL, the logged-i
user or the date. Unique-value validation.

**Anti-spam without a captcha** β€” honeypot, signed time trap, per-address rate
limit, word blocklist, and Akismet when the site already has it. Nothing asks the
limit, word blocklist, and Akismet β€” off by default β€” when the site already has
it and switches it on per form. Nothing asks the
visitor to prove they are human, because that charges them for the site's spam
problem and fails WCAG for a good number of people. A submission judged spam is
**stored**, in a spam status, never silently discarded.
Expand Down
6 changes: 5 additions & 1 deletion assets/js/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -8196,10 +8196,14 @@ ${decls}
"One per line."
),
checkbox(
"Use Akismet when it is installed",
"Check submissions with Akismet",
settings.spam.akismet,
(value) => set("spam.akismet", value)
),
el("p", {
class: "atfb-hint",
text: "Off unless you switch it on. When on, each submission β€” the answers, plus the sender’s IP address and user agent β€” is sent to Akismet’s servers for a spam verdict. Needs the Akismet plugin installed and configured."
}),
checkbox(
"Ask a simple sum before sending",
settings.spam.challenge,
Expand Down
6 changes: 3 additions & 3 deletions assets/js/builder.min.js

Large diffs are not rendered by default.

28 changes: 18 additions & 10 deletions docs/abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,32 @@ note.

| Ability | Does | Permission |
|---|---|---|
| `list-forms` | Every form with id, title, status, theme, shortcode, entry count and distilled fields. The starting point: its ids feed everything else. | read entries *or* edit forms |
| `get-form` | One form in the same shape. Read it before submitting or interpreting entries β€” values are keyed by these field ids. | read entries *or* edit forms |
| `list-forms` | Every form with id, title, status, theme, shortcode, entry count and distilled fields. The starting point: its ids feed everything else. | read entries *or* edit forms; lists only forms the per-form gate lets the caller read |
| `get-form` | One form in the same shape. Read it before submitting or interpreting entries β€” values are keyed by these field ids. | edit forms, *or* read entries **for that form** |
| `list-field-types` | The vocabulary for building: every registered type with slug, label, description, group and value shape. | edit forms |
| `create-form` | Builds a form from a title and a loose field list (`{ type, label, required?, hint?, placeholder?, choices? }`); ids are minted, defaults seeded, shortcode returned. Fields go through the same normaliser a JSON import does. | edit forms |
| `set-form-theme` | Applies an installed theme to a form; refuses invented slugs rather than falling back. | edit forms |
| `list-entries` | Queries submissions with search, date range, status, starred and pagination; answers come back raw (by field id) and readable (by label). | read entries |
| `get-entry` | One submission in full: label, raw value and formatted value per question. | read entries |
| `submit-form` | Submits through the visitor pipeline β€” availability, validation, anti-spam, storage, notifications. Refusals return per-field errors and store nothing. | anyone the channel authenticates |
| `form-report` | The analytics report as data: counts, rates, timeline, distributions, NPS, optional `group_by` breakdown. Prefer it over fetching every entry when the job is summarising. | read entries |
| `list-entries` | Queries submissions with search, date range, status, starred and pagination; answers come back raw (by field id) and readable (by label). | read entries **for that form** |
| `get-entry` | One submission in full: label, raw value and formatted value per question. | read entries (checked against the entry's form) |
| `submit-form` | Submits through the visitor pipeline β€” availability, validation, anti-spam, storage, notifications. Refusals return per-field errors and store nothing. | any authenticated user |
| `form-report` | The analytics report as data: counts, rates, timeline, distributions, NPS, optional `group_by` breakdown. Prefer it over fetching every entry when the job is summarising. | read entries **for that form** |

Where a permission says **for that form**, the `alltfo_can_read_entries`
filter is asked with the `form_id` from the ability's input β€” so a site that
confines a user to a department's forms through that filter confines these
abilities the same way.

## The one honest liberty

`submit-form` mints a valid time-trap signature stamped a minute in the past.
The trap exists to catch bots that post faster than a human can read; an agent
invoking a described ability through an authenticated channel is not that
traffic. Every other check β€” honeypot, rate limit, blocklist, Akismet, and all
of validation β€” runs exactly as it does for a visitor, and spam verdicts still
file the entry as spam rather than discarding it.
invoking a described ability is not that traffic β€” which is exactly why the
ability requires an authenticated user, and an anonymous caller is refused
before this liberty is taken. Anonymous traffic has the rendered form and the
public REST `/submit` route, where the trap runs whole. Every other check β€”
honeypot, rate limit, blocklist, Akismet, and all of validation β€” runs exactly
as it does for a visitor, and spam verdicts still file the entry as spam
rather than discarding it.

## Trying one

Expand Down
16 changes: 14 additions & 2 deletions docs/hooks-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,11 @@ entry under spam rather than rejecting it, so a wrong answer here is always
recoverable from the Entries window.

The five checks are the honeypot, a **signed** time trap, a per-address rate
limit, a word blocklist and Akismet. A sixth β€” an arithmetic challenge β€” is off
by default and is the only one that asks the visitor to do anything.
limit, a word blocklist and Akismet. Akismet is off by default β€” switching it
on per form is the site owner's decision, because it sends each submission
(answers, IP, user agent) to Akismet's servers. A sixth check β€” an arithmetic
challenge β€” is also off by default and is the only one that asks the visitor
to do anything.

The challenge's answer is signed with `alltfo_sign_challenge()` and never sent to
the browser. A challenge whose expected answer travels in the page alongside the
Expand Down Expand Up @@ -513,6 +516,11 @@ sending a different one each time.
do_action( 'alltfo_before_submission', int $form_id, array $request, array $schema );
```

Since 0.4.0 `$request` arrives deep-sanitised β€” every key and scalar has been
through `alltfo_sanitize_request()` before this fires. The per-field-type
sanitisation still runs after it, so treat these values as clean text, not as
the typed values `alltfo_entry_created` receives.

### `alltfo_entry_created` β€” Action β€” *Stable*

```php
Expand Down Expand Up @@ -1074,6 +1082,10 @@ apply_filters( 'alltfo_can_read_entries', bool $can, int $form_id );
The seam for per-form permissions: a site can let a department read only the
entries of the forms it owns by returning false for every other id.

The abilities honour it with the form in hand: `get-form`, `list-entries` and
`form-report` ask this filter with the `form_id` from their input, and
`list-forms` asks it per form before listing one.

---

## Assets
Expand Down
57 changes: 47 additions & 10 deletions includes/abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ function alltfo_register_abilities() {
return alltfo_can_read_entries() || alltfo_can_edit_forms();
};

// The per-form gates. An ability that names a form in its input asks the
// question about *that* form, so the `alltfo_can_read_entries` filter β€” the
// seam a site uses to confine a department to its own forms β€” is consulted
// with the id, not with the blanket 0. The Abilities API hands the input to
// the permission callback whenever an input schema is declared.
$read_form = static function ( $input ) {
return alltfo_can_edit_forms() || alltfo_can_read_entries( absint( $input['form_id'] ?? 0 ) );
};

$read_form_entries = static function ( $input ) {
return alltfo_can_read_entries( absint( $input['form_id'] ?? 0 ) );
};

// Submitting is what visitors do, so no capability is asked for β€” but the
// ability *is* held to an authenticated user, because it mints the time-trap
// signature a rendered form would carry, and that liberty is only honest on
// a channel that identifies its caller. Anonymous traffic has the rendered
// form and the public REST route, where every anti-bot check runs whole.
$is_authenticated = static function () {
return is_user_logged_in();
};

wp_register_ability(
'allterrain-forms/list-forms',
array(
Expand Down Expand Up @@ -159,7 +181,7 @@ function alltfo_register_abilities() {
'label' => __( 'Get a form', 'allterrain-forms' ),
'description' => __( 'Reads one form: title, status, theme, embed shortcode, entry count, and every question with its field id, type, label, required flag and choices. Use it before submitting to a form or interpreting its entries β€” values in entries are keyed by these field ids, and choice answers store the choice value, not its label.', 'allterrain-forms' ),
'category' => $category,
'permission_callback' => $read_anything,
'permission_callback' => $read_form,
'execute_callback' => 'alltfo_ability_get_form',
'input_schema' => array(
'type' => 'object',
Expand Down Expand Up @@ -280,7 +302,7 @@ function alltfo_register_abilities() {
'label' => __( 'List entries', 'allterrain-forms' ),
'description' => __( 'Queries a form’s submissions. Each entry returns its answers twice: raw values keyed by field id, and a human-readable line per question with the label resolved β€” use the readable form for summaries and the raw form for computation. Filter by free-text search, date range (after/before, Y-m-d), status (alltfo-unread, alltfo-read, alltfo-spam) and starred; paginate with page/per_page.', 'allterrain-forms' ),
'category' => $category,
'permission_callback' => $read_entries,
'permission_callback' => $read_form_entries,
'execute_callback' => 'alltfo_ability_list_entries',
'input_schema' => array(
'type' => 'object',
Expand Down Expand Up @@ -343,9 +365,9 @@ function alltfo_register_abilities() {
'allterrain-forms/submit-form',
array(
'label' => __( 'Submit a form', 'allterrain-forms' ),
'description' => __( 'Submits answers to a form through the same pipeline a visitor uses: availability, validation, anti-spam, storage, notification emails and confirmations all run. Values are keyed by field id (from get-form); choice fields take the choice value, checkbox groups take an array of them. On validation failure nothing is stored and the per-field errors are returned β€” fix them and call again.', 'allterrain-forms' ),
'description' => __( 'Submits answers to a form through the same pipeline a visitor uses: availability, validation, anti-spam, storage, notification emails and confirmations all run. Requires an authenticated user. Values are keyed by field id (from get-form); choice fields take the choice value, checkbox groups take an array of them. On validation failure nothing is stored and the per-field errors are returned β€” fix them and call again.', 'allterrain-forms' ),
'category' => $category,
'permission_callback' => '__return_true',
'permission_callback' => $is_authenticated,
'execute_callback' => 'alltfo_ability_submit_form',
'input_schema' => array(
'type' => 'object',
Expand Down Expand Up @@ -376,7 +398,7 @@ function alltfo_register_abilities() {
'label' => __( 'Form report', 'allterrain-forms' ),
'description' => __( 'The analytics for one form as structured data: views, starts, submissions, conversion and completion rates, a 90-day timeline, per-question answer distributions, numeric summaries, and a Net Promoter Score panel where a 0–10 question exists. Pass group_by (a choice field id) to break every question down by how a grouping question was answered. Prefer this over fetching every entry when the job is summarising.', 'allterrain-forms' ),
'category' => $category,
'permission_callback' => $read_entries,
'permission_callback' => $read_form_entries,
'execute_callback' => 'alltfo_ability_form_report',
'input_schema' => array(
'type' => 'object',
Expand All @@ -402,7 +424,12 @@ function alltfo_register_abilities() {
}

/**
* Every form, distilled.
* Every form the caller may see, distilled.
*
* Each form is held against the per-form gate before it is listed: a user the
* `alltfo_can_read_entries` filter confines to a department's forms gets that
* department's list, not the site's. Editors see everything β€” they could open
* any form in the builder anyway.
*
* @since 0.3.0
*
Expand All @@ -420,7 +447,14 @@ function alltfo_ability_list_forms() {
)
);

return array_map( 'alltfo_ability_form', $posts );
$posts = array_filter(
$posts,
static function ( $post ) {
return alltfo_can_edit_forms() || alltfo_can_read_entries( $post->ID );
}
);

return array_values( array_map( 'alltfo_ability_form', $posts ) );
}

/**
Expand Down Expand Up @@ -581,9 +615,12 @@ function alltfo_ability_get_entry( $input ) {
* including a *valid* time-trap signature stamped far enough in the past to
* pass the minimum-time check. Minting it here is not defeating the trap: the
* trap exists to catch bots that post faster than a human can read, and an
* agent invoking a described ability through an authenticated channel is not
* the traffic it hunts. Every other check β€” honeypot, rate limit, blocklist,
* Akismet, validation β€” runs exactly as it does for a visitor.
* agent invoking a described ability is not the traffic it hunts β€” which is
* exactly why the ability's permission callback requires an authenticated
* user. An anonymous caller never reaches this function; they have the
* rendered form and the public REST route, where the trap runs whole. Every
* other check β€” honeypot, rate limit, blocklist, Akismet, validation β€” runs
* exactly as it does for a visitor.
*
* @since 0.3.0
*
Expand Down
7 changes: 6 additions & 1 deletion includes/actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,12 @@ function alltfo_action_webhook( $settings, $context ) {
$headers['X-ATF-Signature'] = 'sha256=' . hash_hmac( 'sha256', $body, (string) $settings['secret'] );
}

$response = wp_remote_post(
// The safe variant, not `wp_remote_post()`: the URL was validated above,
// but only the *first* URL. A webhook endpoint that answers 302 would have
// the followed redirect land wherever it pointed β€” an internal address
// included β€” with no second look. `wp_safe_remote_post()` re-validates
// every hop.
$response = wp_safe_remote_post(
$url,
array(
// Short, and blocking. A webhook that hangs would hold the visitor
Expand Down
8 changes: 7 additions & 1 deletion includes/confirmations.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,13 @@ function alltfo_resolve_confirmation( $schema, $values, $entry_id, $form_id ) {
? $chosen['message']
: __( 'Thank you. Your submission has been received.', 'allterrain-forms' );

$resolved['message'] = alltfo_replace_merge_tags( $message, $context );
// Kses'd here, at resolution, so both render paths carry the same
// armour: the no-JS path escapes again in `alltfo_success_screen_html()`,
// but the AJAX path hands this string straight to the bundle, which
// injects it as HTML. Field values inside it are already tag-free β€”
// every field type's sanitiser strips tags β€” so this is the second
// layer, not the first.
$resolved['message'] = wp_kses_post( alltfo_replace_merge_tags( $message, $context ) );
} else {
$resolved['url'] = alltfo_confirmation_url( $chosen, $context );

Expand Down
20 changes: 12 additions & 8 deletions includes/rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,9 @@ function alltfo_register_rest_routes() {
// began filling this form in" ping comes from a logged-out visitor
// on the front end. It records nothing but an anonymous counter
// bump against a published form β€” the handler verifies the form
// exists and has analytics switched on before touching anything β€”
// and nothing is read back or sent to any external service.
// exists *and is published*, and the recorder refuses forms whose
// analytics are switched off β€” and nothing is read back or sent to
// any external service.
'permission_callback' => '__return_true',
'args' => array(
'form_id' => array(
Expand Down Expand Up @@ -492,14 +493,17 @@ function alltfo_rest_track( $request ) {
$form_id = absint( $request->get_param( 'form_id' ) );
$event = sanitize_key( (string) $request->get_param( 'event' ) );

// Only a real form's counter can be bumped: an id pointing at another post
// type, or at nothing, is dropped without side effects, so the public
// route cannot be used to write meta onto arbitrary posts. Status is
// deliberately not checked β€” the renderer records views for any existing
// form, and the two counters must agree about which forms count.
// Only a published form's counter can be bumped: an id pointing at another
// post type, at a draft, trashed or archived form, or at nothing, is
// dropped without side effects β€” so the public route can neither write
// meta onto arbitrary posts nor be used to probe or inflate forms that
// are not public. A draft embedded somewhere counts its views (the
// renderer runs as the page does) but not starts from this route; that
// small asymmetry is the price of the unauthenticated surface staying
// confined to what is actually published.
$form = $form_id ? get_post( $form_id ) : null;

if ( $form && ALLTFO_FORM_TYPE === $form->post_type && 'start' === $event ) {
if ( $form && ALLTFO_FORM_TYPE === $form->post_type && 'publish' === $form->post_status && 'start' === $event ) {
alltfo_record_start( $form_id );
}

Expand Down
7 changes: 6 additions & 1 deletion includes/schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
* stored but the retention default is 0 (keep forever) -- a plugin should not
* quietly delete somebody's data on a schedule they never chose.
*
* Akismet is **off**, even on a site that has it installed and configured.
* Switching it on sends each submission -- the answers, the sender's IP and
* user agent -- to Akismet's servers, and data leaving the site is a decision
* the site owner makes per form, never a default a plugin makes for them.
*
* @since 0.1.0
*
* @return array A complete schema with no fields.
Expand Down Expand Up @@ -71,7 +76,7 @@ function alltfo_default_schema() {
'timeTrap' => 3,
'rateLimit' => 10,
'blocklist' => '',
'akismet' => true,
'akismet' => false,
'challenge' => false,
),
'storage' => array(
Expand Down
7 changes: 4 additions & 3 deletions includes/spam.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
* Spam screening without a captcha.
*
* Five checks, none of which asks the visitor to do anything: a honeypot, a time
* trap, a per-IP rate limit, a word blocklist, and Akismet when the site already
* has it. An optional arithmetic challenge exists for forms under sustained
* attack, and is off by default.
* trap, a per-IP rate limit, a word blocklist, and Akismet β€” off by default,
* because it sends the submission off-site, and that is the site owner's call
* to make per form. An optional arithmetic challenge exists for forms under
* sustained attack, and is off by default too.
*
* No reCAPTCHA, no hCaptcha, no "select all the traffic lights". Those work, but
* they charge the visitor for the site's spam problem, they are a WCAG failure
Expand Down
Loading
Loading