Summary
Invite tokens generated by Invite::create_invite_url() are stored verbatim in wp_postmeta under _map_invite_token. Any path that exposes postmeta — DB dump, backup archive, SQL injection elsewhere on the site, slow-query log that captures meta_value in a WHERE clause, read-only DB role, log-shipping pipeline — leaks live, usable invite credentials.
Where
includes/class-map-invite.php:
create_invite_url() lines 116–120 — stores raw token
get_post_by_token() lines 77–94 — looks up by raw token in meta_value
get_invite_url() lines 102–108 — reads raw token and rebuilds URL
Impact
The tokens have ample entropy (32 chars, ~190 bits), so brute-force isn't the concern — leakage via storage is. Anyone with read access to the wp_postmeta table has full invite access until revoked.
Suggested fix
Store a hash, not the plaintext. Options:
Option A — single-value hash. Keep the same single-meta shape; store hash( 'sha256', $token ); hash on lookup; compare with hash_equals(). Simplest, but get_invite_url() can no longer reconstruct the URL after creation — so we need to return the URL at creation time only and not expose a GET endpoint that reveals it later. The sidebar already wouldn't show a usable link after page reload; we'd need to change the UX to "link exists / revoke / regenerate" rather than displaying the URL.
Option B — id.secret token format. Generate base64url( random(8) ) . '.' . base64url( random(24) ). Store id as meta key (or in a dedicated structure) and hash( 'sha256', $secret ) as the value. Lookup splits the token, selects by id (cheap), then hash_equals() on the hashed secret. This preserves the "I can show the link to the author in the sidebar any time" UX only if we keep plaintext client-side — which defeats the point. In practice also gives up the stored-URL UX.
Recommendation: Option A, and change the sidebar so "Generate link" displays the URL once (with a copy button) and subsequent visits show only "Link active — revoke / regenerate". This matches how GitHub PATs, deploy tokens, etc. behave and sets correct user expectations about token secrecy.
Migration
No migration path is needed if we're comfortable invalidating outstanding tokens on upgrade (the plugin has no releases yet). If a migration is wanted later, a one-shot upgrader_process_complete-style callback could rehash in place, but plaintext-to-hash is a lossy migration for already-issued tokens anyway.
Tests to add
- Stored meta value !== raw token.
- Raw token successfully resolves to a post via
get_post_by_token().
- Tampered / truncated token returns
null.
- Revoke removes the stored hash.
References
Internal security review, Apr 2026.
Summary
Invite tokens generated by
Invite::create_invite_url()are stored verbatim inwp_postmetaunder_map_invite_token. Any path that exposes postmeta — DB dump, backup archive, SQL injection elsewhere on the site, slow-query log that capturesmeta_valuein aWHEREclause, read-only DB role, log-shipping pipeline — leaks live, usable invite credentials.Where
includes/class-map-invite.php:create_invite_url()lines 116–120 — stores raw tokenget_post_by_token()lines 77–94 — looks up by raw token inmeta_valueget_invite_url()lines 102–108 — reads raw token and rebuilds URLImpact
The tokens have ample entropy (32 chars, ~190 bits), so brute-force isn't the concern — leakage via storage is. Anyone with read access to the
wp_postmetatable has full invite access until revoked.Suggested fix
Store a hash, not the plaintext. Options:
Option A — single-value hash. Keep the same single-meta shape; store
hash( 'sha256', $token ); hash on lookup; compare withhash_equals(). Simplest, butget_invite_url()can no longer reconstruct the URL after creation — so we need to return the URL at creation time only and not expose a GET endpoint that reveals it later. The sidebar already wouldn't show a usable link after page reload; we'd need to change the UX to "link exists / revoke / regenerate" rather than displaying the URL.Option B —
id.secrettoken format. Generatebase64url( random(8) ) . '.' . base64url( random(24) ). Storeidas meta key (or in a dedicated structure) andhash( 'sha256', $secret )as the value. Lookup splits the token, selects byid(cheap), thenhash_equals()on the hashed secret. This preserves the "I can show the link to the author in the sidebar any time" UX only if we keep plaintext client-side — which defeats the point. In practice also gives up the stored-URL UX.Recommendation: Option A, and change the sidebar so "Generate link" displays the URL once (with a copy button) and subsequent visits show only "Link active — revoke / regenerate". This matches how GitHub PATs, deploy tokens, etc. behave and sets correct user expectations about token secrecy.
Migration
No migration path is needed if we're comfortable invalidating outstanding tokens on upgrade (the plugin has no releases yet). If a migration is wanted later, a one-shot
upgrader_process_complete-style callback could rehash in place, but plaintext-to-hash is a lossy migration for already-issued tokens anyway.Tests to add
get_post_by_token().null.References
Internal security review, Apr 2026.