Skip to content

Security hardening: authorization, input validation, and logging#2923

Merged
corsacca merged 16 commits into
developfrom
security-review
Jun 10, 2026
Merged

Security hardening: authorization, input validation, and logging#2923
corsacca merged 16 commits into
developfrom
security-review

Conversation

@corsacca

Copy link
Copy Markdown
Member

Summary

Security hardening across authorization, input validation, authentication, and logging. These came out of a focused security review of the theme. None of these are exploitable by an unauthenticated attacker — the global REST authentication wall (restrict-rest-api.php) blocks anonymous REST access, so every item below requires at least a low-privilege authenticated session; several require a specific capability. The changes are additive guards and validation with no intended behavior change for legitimate users.

Changes by area

Access control / authorization

  • dynamic_records_map metrics endpoints (cluster_geojson / points_geojson / get_grid_totals / get_list_by_grid_id) now apply the same capability + dt_share scoping as post_type_geojson, instead of returning every located record's name + coordinates to any authenticated user.
  • merge_posts and revert_post_activity_history now require can_update (both write to records but were gated on can_view).
  • remove_shared now protects the assigned user's foundational share (the previous guard was unreachable) and explicitly allows self-removal.
  • People-groups locale search is moved inside the permission group so its OR clause no longer escapes the share/status/type filters.

Injection

  • date_range_activity: cast the user_select value to an integer before building the meta-value clause (was concatenated into the query unescaped).
  • Contact receive-transfer: validate the transfer token before processing, and decode incoming postmeta with unserialize(..., ['allowed_classes' => false]) to remove a PHP object-injection sink.

Input / upload validation

  • Object-storage uploads now derive the content type from the file's bytes (finfo) instead of trusting the client, and store anything that isn't a raster image as application/octet-stream so uploads can't be served as inline-executable content.
  • plugin-install now rejects non-http(s) schemes and hosts that resolve to private/loopback/link-local/reserved ranges, and downloads via the WP HTTP API (closes a server-side request forgery vector; admin-only endpoint).

Authentication hardening

  • Added per-IP brute-force throttling to the jwt-auth/v1/token endpoint (transient-based, scoped to that endpoint, disable-able via the dt_enable_jwt_login_throttle filter for sites that already run a security plugin).
  • get_settings uses a real !$user->exists() check instead of an always-false guard.
  • Site-link transfer tokens are compared with hash_equals() (constant-time; removes a numeric-string type-juggling edge). Token format is unchanged, so existing site links keep working.

Sensitive data in logs

  • Removed a dt_write_log() call that wrote the cleartext new password on password change.
  • Removed debug error_log() calls in the DT Home magic-link endpoint that logged the request params (including the magic-link key) on every request.

Notes

  • Site-link token format intentionally unchanged (interop with linked sites); the md5→HMAC and validity-window changes are left for a future versioned protocol change.
  • A few items surfaced by the review were intentionally left as-is by design (e.g. cross-CRM duplicate detection, the open-registration default role) and are not part of this PR.

🤖 Generated with Claude Code

corsacca added 16 commits June 10, 2026 12:23
- authorize and scope cluster_geojson, get_grid_totals,
  get_list_by_grid_id and points_geojson callbacks
- personal map limits results to records shared with the current user;
  records map requires all-access or project metrics permission
cast the user id to an integer before building the meta_value clause
so a crafted value.ID can no longer break out of the query
- validate the transfer token before inserting or processing meta,
  rejecting forged or missing tokens
- decode postmeta values with allowed_classes disabled so serialized
  objects can no longer be instantiated
wp_get_current_user() always returns an object, so check ->exists()
so the settings endpoint fails closed for logged-out requests
remove leftover debug error_log calls in the dt-home endpoint that
wrote the magic-link public_key to the log on every request
remove the dt_write_log call that wrote the new password to debug.log
render activity revert values, merge target name, and delete-filter
name as text instead of html to prevent stored xss
both operations write to records but were gated on can_view; check
can_update so a view-only user cannot revert or merge records
cap repeated failed logins per client ip on /jwt-auth/v1/token using
transients; scoped to the token endpoint and disableable via filter
the prior guard was unreachable; a user with only a share could remove
the assigned user's share and lock them out. allow self-removal, and
restrict removing the assignee's share to that user or update_any holders
detect the real mime type from the file bytes instead of trusting the
client, and store anything that is not a raster image as octet-stream
replace loose == checks with hash_equals so token matching is
constant-time and not subject to numeric string type juggling
reject non-http(s) schemes and hosts resolving to private, loopback,
link-local or reserved ranges, and fetch via the http api
the locale clause was appended after the search parenthesis, so its OR
escaped the share gate; move it inside so access filters still apply
state the reason (the user is assigned to the record) and make the
message translatable instead of a generic permission error
@github-actions

Copy link
Copy Markdown

Code Review

Medium

dt-metrics/records/dynamic-records-map.php:174post_type_geojson not covered by authorize_and_scope_query

The PR adds authorize_and_scope_query to cluster_geojson, get_grid_totals, get_list_by_grid_id, and points_geojson, but post_type_geojson is left with its original inline check that hardcodes contacts capabilities regardless of the actual post_type param:

// line 195-200 — capability is always contacts, even if $params['post_type'] is 'groups'
if ( ( $slug === 'personal' ) && current_user_can( 'access_contacts' ) ) {
    $has_permission = true;
}
if ( ( $slug === 'records' ) && ( current_user_can( 'dt_all_access_contacts' ) || current_user_can( 'view_project_metrics' ) ) ) {
    $has_permission = true;
}

A user who holds dt_all_access_contacts but not dt_all_access_groups can query post_type_geojson with post_type=groups (records slug) and receive group location data. authorize_and_scope_query correctly keys the capability check off $post_type and would close this gap if applied here.


dt-core/jwt-rate-limit.php:77-84 — Non-atomic transient counter defeats protection under concurrent burst requests

set_transient( $key, (int) get_transient( $key ) + 1, $window );

get_transient + set_transient is a read-modify-write with no locking. Under MySQL-backed transients (the default), ten simultaneous failed attempts can all read the same counter value and each write oldValue + 1, leaving the counter at oldValue + 1 instead of oldValue + 10. An attacker issuing bursts of ~10 parallel requests can make many more than dt_jwt_login_throttle_max_attempts total guesses before being blocked. Consider an atomic $wpdb->query( "INSERT INTO ... ON DUPLICATE KEY UPDATE option_value = option_value + 1" ) pattern or equivalent, similar to the approach used in some WP rate-limiter plugins.


Summary

The PR is a meaningful security hardening set — cleaning up debug error_log calls, fixing XSS sinks (.html().text()), hardening SSRF in plugin install, adding hash_equals for timing-safe token comparison, fixing PHP object injection via unserialize with allowed_classes: false, closing the peoplegroups SQL OR bypass in search_viewable_post, and adding JWT brute-force throttling. The two medium issues above are the only gaps worth addressing before merge.

@corsacca corsacca merged commit 122d741 into develop Jun 10, 2026
4 of 5 checks passed
@corsacca corsacca deleted the security-review branch June 11, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant