Skip to content

audit(theme): defense-in-depth re-sanitization of REST inputs inside handlers #111

Description

@JohnRDOrazio

Background

PR #110 review surfaced the question: should the extracted REST handlers re-sanitize their input (`submitter_email`, `url`, `category`, `project_url`, etc.) on entry, even though every route's `args` block already declares a `sanitize_callback` (`sanitize_email` / `esc_url_raw` / `sanitize_text_field` / `sanitize_textarea_field`) that runs during REST dispatch?

Current convention across every extracted handler:

```
includes/handlers/
├── academic-collaboration.php
├── community-channel.php
├── deploy-translation.php
├── flush-opcache.php
├── link-translations.php
├── local-group.php
├── project-status.php
├── refer-community-project.php ← review flagged
├── refer-local-group.php ← review flagged
├── relationship.php
├── send-verification-code.php
├── submit-project-send-code.php
├── submit-project.php ← review flagged
├── team-member.php
├── translate.php
└── update-disposable-domains.php
```

None of them re-sanitize. They all trust the REST framework's `sanitize_callback` to have run by the time the handler sees the request — which is true under the standard `register_rest_route` dispatch path.

Question to settle

Should we flip the convention to defense-in-depth — re-sanitize on entry into the handler — and if so, apply it consistently across all 16 handlers?

Arguments for re-sanitizing:

  • The REST args sanitize_callback only runs when the handler is invoked via REST dispatch. If the function is ever called directly (custom plugin code, AJAX shim, future refactor), the sanitisation chain is bypassed.
  • The handler's public contract becomes self-contained — readers don't have to cross-reference the args block in functions.php to know what's safe.
  • Consistent with WP-VIP / hardened-codebase conventions where every persistence sink and every outbound email is the last line of defense.

Arguments against:

  • WP's REST framework is the standard ingress; bypassing it is a misuse pattern. Adding internal sanitisation suggests the function is callable from anywhere, which it isn't.
  • Re-sanitising via `sanitize_email` / `esc_url_raw` etc. is idempotent (passing already-clean input through them returns the same value), so the runtime cost is small but the code noise per handler is real.
  • 16 handlers × ~5 fields each = ~80 new lines of glue with no behavioural change under the normal call path.

Suggested scope (if accepted)

For each handler, immediately after the `function_exists` guards but before any logic that touches request values:

```php
$email = sanitize_email((string) $request['submitter_email']);
$url = esc_url_raw((string) $request['url']);
$location = sanitize_text_field((string) $request['location']);
$description = sanitize_textarea_field((string) $request['description']);
// ... etc — only the fields the handler actually reads
```

Then replace every `$request['foo']` reference with the corresponding sanitised local. Tests would assert that the persisted post meta / outgoing emails contain the sanitised values, not the raw `$request` input.

Out of scope here

  • Sanitising fields the handler doesn't read (no churn for parameters that flow only through the REST args block to never be touched again).
  • Validation beyond sanitisation — `is_email()` etc. stay where they are.
  • Plugin tree (`wordpress/plugins/cdcf-redis-translations/`) — its three handlers are simpler and don't read URL/email inputs.

Why this is a separate issue, not part of any single PR

Re-sanitisation is a cross-cutting decision. If we apply it to three of the sixteen handlers in isolation (which is what review on #110 effectively proposed), we get:

  • Inconsistency between defensive and trusting handlers — future contributors don't know which pattern to follow.
  • A "did we get them all" review burden each time a new handler lands.

Resolving the convention once across all 16 is cheaper than tracking the inconsistency over the long tail.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions