Security
Developer + security-audit reference
This page documents the auth / nonce / rate-limit / CSP model. Useful for security audits and custom code that touches Wallet Central. Admins and store owners can skip — defaults are safe out of the box.
Wallet Central is a money UI. Every input + every endpoint hardened.
Auth Gate
WKWP_Central_Endpoint::maybe_render runs template_redirect priority 1. If is_user_logged_in() === false:
wp_safe_redirect(
add_query_arg(
'redirect_to',
rawurlencode( $current_url ),
wp_login_url()
)
);
exit;
No content rendered. No PHP runs further. Anonymous visitors never see Wallet Central HTML.
Nonce Protection
Every form + every AJAX action carries a WordPress nonce.
| Surface | Nonce field / header |
|---|---|
| Forms (Withdraw, Send, Add Funds, KYC) | <input type="hidden" name="<action>_nonce" value="<wp_create_nonce>"> |
| AJAX (Settings save, recipient search, OTP request, QR resolve, QR pay) | X-WP-Nonce header |
REST (/wp-json/wkwc_wallet/v1/verify_otp/{otp}) | X-WP-Nonce header |
Nonce TTL: 24h (WP default). Stale → server returns 403 invalid_nonce.
Action Allowlist
WKWP_Central_Settings_Ajax::handle rejects any meta key not in the allowlist:
const ALLOWED_KEYS = [
'_wkwp_auto_topup_enabled',
'_wkwp_auto_topup_threshold',
'_wkwp_auto_topup_amount',
'_wkwp_notify_credit_email',
'_wkwp_notify_debit_email',
'_wkwp_notify_low_balance_email',
'_wkwp_notify_transfer_received_sms',
'_wkwp_notify_withdrawal_paid_sms',
'_wkwp_notify_kyc_email',
'_wkwp_transfer_locked',
];
Unknown key → 400 invalid_key. Stops customers from writing arbitrary user meta via the settings AJAX.
Per-Key Validation
Even allowlisted keys validate values:
| Key | Rule |
|---|---|
_wkwp_auto_topup_threshold | numeric, ≥ 0, ≤ wallet_settings.max_credit |
_wkwp_auto_topup_amount | numeric, > 0 |
_wkwp_*_email / _wkwp_*_sms | 0 / 1 |
_wkwp_transfer_locked | 0 / 1 |
Rules registered via wkwp_central_settings_save_validate filter — extensible.
KYC Server-Side Enforcement
UI gates are belt-and-braces. Authoritative gates run server-side regardless of UI:
| Enforcer | Hook | Scope |
|---|---|---|
WKWP_Wallet_Withdrawal_KYC_Guard | wp_loaded priority 5 | rejects withdrawal POSTs |
WKWP_Wallet_Transfer::check_user_kyc_access | called from AJAX handler | rejects transfer attempts |
WKWP_QR_Pay::handle_pay | called from AJAX handler | rejects QR pay attempts |
wkwc_wallet_show_method_on_checkout filter | gateway visibility | hides wallet from checkout |
Means: even a manually-crafted curl request bypassing the UI hits the server-side check and gets rejected.
CSRF
WordPress nonces are CSRF tokens. They're tied to:
- Action name (e.g.
wkwp_central_settings_save) - User ID
- Session
Stolen URL or replayed POST from another origin will not match — wp_verify_nonce returns false → 403.
Idempotency
State-changing operations are idempotent where possible:
| Operation | Idempotency mechanism |
|---|---|
| Recharge order completes | _wkwp_topup_credited = yes order meta — repeat woocommerce_order_status_completed no-ops |
| Cashback rule fires | _wkwp_cashback_processed = yes |
| Top-up bonus | _wkwp_bonus_credited = yes |
| Bulk credit row | UNIQUE on client_dedupe_key |
| Transfer OTP | consumed_at flag — verify-once |
| Referral payout | wallet_referrals.reward_amount > 0 lock |
| BNPL repay | atomic update inside DB transaction |
Means duplicate requests (network retry, double-click) never cause double-credit.
Rate Limiting
| Surface | Limit |
|---|---|
| Wallet Central pageview | none (per-user pages, no abuse vector) |
| Settings AJAX | 60 requests / minute per user |
| Recipient search AJAX | 30 / minute |
| OTP request | 5 / minute per user (rolling) |
| OTP verify | 10 / minute per user |
| QR pay AJAX | 10 / minute per user |
REST verify_otp | 60 / minute per IP |
Exceeded → 429 too_many_requests with Retry-After header.
Tunable via filter wkwp_central_rate_limit_<scope>.
Headers
WKWP_Central_Endpoint::send_headers writes:
Content-Type: text/html; charset=UTF-8
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: same-origin
Wallet Central never embeds in iframes from other origins. Never gets cached at HTTP layer. Never sniffed for MIME confusion.
Content Security Policy
Strict CSP recommended. Default policy works:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://secure.gravatar.com;
connect-src 'self';
font-src 'self' data:;
frame-ancestors 'self';
'unsafe-inline' is unfortunately needed for inline event handlers in the legacy submodule embeds. Future versions may move to nonce-based inline scripts.
File Upload Hardening (KYC)
| Layer | Check |
|---|---|
| MIME whitelist | only image/jpeg, image/png, image/webp, application/pdf |
| Size cap | 5 MB per file (configurable) |
| Filename sanitisation | sanitize_file_name() |
| Storage outside public dir | wp-content/uploads/wallet-kyc/<user_id>/ with .htaccess deny-all |
| Image proxy with capability check | direct URL access blocked |
| EXIF strip on JPEG | optional via wkwp_kyc_strip_exif filter |
SQL Injection
Every DB query uses $wpdb->prepare. No raw string interpolation. Tested under WP_DEBUG.
XSS
Every render path uses one of:
esc_html()esc_attr()esc_url()wp_kses_post()(for admin-controlled rich text only)
Customer-typed values (notes, full names) never rendered without escaping.
Authentication Bypass via REST API Keys
REST keys (Basic Auth consumer_key:consumer_secret) honoured only on the /wp-json/wkwp-wallet/v1/* namespace. The customer-facing AJAX surface (wp_ajax_wkwp_central_*) requires cookie auth — REST keys not accepted.
Audit Logging
State changes write rows to wkwc_wallet_transactions. Reference column carries the audit tag (bulk:<batch_id>:row:<id>, transfer:#<request_id>, withdrawal:#<row_id>).
For higher audit needs, hook wkwp_wallet_balance_changed to mirror to a SIEM:
add_action( 'wkwp_wallet_balance_changed', function( $user_id, $amount, $type, $balance_after ) {
error_log( json_encode( [
'event' => 'wallet_change',
'user_id' => $user_id,
'amount' => $amount,
'type' => $type,
'balance' => $balance_after,
'remote_ip' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'timestamp' => current_time( 'mysql' ),
] ) );
}, 10, 4 );
Session Hijack Mitigation
WordPress session tokens (cookie-based) rotate on login. Wallet Central doesn't store any session secret beyond what WP already manages.
For two-factor auth, install a 2FA plugin (e.g. WordFence 2FA) — Wallet Central inherits whatever auth gate WP enforces.
Mass Assignment
Settings AJAX uses an explicit allowlist — no mass assignment possible. Form POSTs are mapped explicitly to typed fields, never extract($_POST).
Common Attack Vectors → Defence
| Attack | Defence |
|---|---|
| CSRF on Settings save | nonce |
| OTP brute force | 10 / minute rate limit + 6-digit space → ~10 attempts / 1M tries |
| Replay-attack on OTP | bcrypt hashed + consumed_at lock |
| KYC document leak | files outside public dir + image proxy with cap check |
| QR forgery | HMAC sig over wallet_id:nonce |
| API key theft | scope + IP whitelist + revoke action |
| Race-condition double-credit | DB row lock under SELECT FOR UPDATE |
| Insufficient-balance overflow | Wallet_Core::debit() re-fetches under lock |
Test Plan
- Anon →
/wallet-central/→ confirm 302 to wp-login - Open browser dev tools → fire Settings AJAX without nonce header → confirm 403
- Fire Settings AJAX with bogus key → confirm 400
- Submit Withdraw without nonce → legacy handler returns nonce-fail
- Manually POST to OTP verify with wrong code → 401 invalid_otp
- Burst 100 settings saves in 1 sec → confirm 429
- Upload
.exeto KYC → MIME reject
Hooks
| Hook | Type | When |
|---|---|---|
wkwp_central_settings_save_keys | filter | extend allowlist |
wkwp_central_settings_save_validate | filter | per-key validation |
wkwp_central_rate_limit_<scope> | filter | override rate limit |
wkwp_central_send_headers | action | inject extra headers |
wkwp_central_csp_policy | filter | mutate CSP value |
Pen-Test Suggestions
For a third-party security audit, focus on:
- KYC file upload bypass attempts (MIME spoofing)
- OTP brute force (rate limit + bcrypt)
- API key scope escalation (read → write)
- Nonce replay across users
- QR HMAC forgery
- Withdrawal request enumeration via row IDs (should 403 for non-owner)
All of these are covered by current defences but worth re-confirming per release.
