Stand up the monorepo skeleton and land the first Phase 0 artifacts: pairing threat model, plugin catalog, builder detect, 20-prompt golden harness, and synthetic 2GB mirror timing.
6.7 KiB
Spike: pairing threat model (R9)
Status: done
Question
What stops a leaked URL from owning the site?
Done when
Written threat model that becomes the Sprint 2 / Layer 2 auth tests:
- 8+ character pairing code
- 5-minute TTL
- 5-attempt lockout
- HMAC request signing
- hashed + scoped tokens (read vs deploy)
Result
The plugin is a privileged backdoor: files, DB, WP-CLI. A leaked URL, a guessed pairing code, or a stolen bearer token is site ownership. Isolation of the sandbox does not help — this boundary is the live site.
Locked flow
Wursor generates the pairing code (bound to the signed-in account). The user pastes it into the plugin. The plugin redeems it with the site URL. Tokens are issued once.
This matches PRD §7.1.4. The Wursor_Auth::generate_pairing_code() sketch in IMPLEMENTATION.md is the wrong direction — plugin-local generate/redeem cannot bind the code to an account before the site is known. Sprint 2 tests follow this note, not that sketch.
User (signed in) → POST /sites/pair → Wursor stores pending pairing
User pastes code in plugin admin
Plugin → POST https://api.wursor…/sites/redeem { code, site_url }
Wursor binds site_url, returns read_token + deploy_token + hmac_secret (once)
Plugin stores hashes + encrypted hmac_secret
Wursor stores tokens encrypted (it must send them later)
Wursor is the HTTPS client. The plugin is the server. Tokens never appear in query strings or logs.
Protocol
Pairing code
- Alphabet:
[A-Z0-9], length ≥ 8. Generate 8.36^8 ≈ 2.8e12. - Bound to
account_idat creation. Not reusable after success. expires_at = created_at + 300s. Clock for tests is injectable (advance_clock).- After 5 failed redeems on that code,
locked = true. Further redeems fail even if the code is correct. - Redeem also fails if
site_urlis nothttpsor does not parse as a URL. - One successful redeem. Second redeem of the same code fails.
Tokens
| Token | Scope | Plugin endpoints |
|---|---|---|
read |
site-info, file read, DB read, preflight | GET only |
deploy |
file write, DB write, WP-CLI, prepare/commit, rollback | mutating |
- 256-bit random, encoded unpadded base64url, shown once.
- Plugin stores
SHA-256(token)only. Compare withhash_equals. - Wursor stores ciphertext (envelope key, not plaintext in Postgres).
- A
readtoken on a deploy route returns 403. Adeploytoken may call read routes. - Rotation: Wursor issues a new pair; plugin replaces hashes; old hashes stop working.
- Disconnect: both hashes deleted; Wursor ciphertext deleted.
HMAC (every plugin request)
canonical = timestamp + "\n" + METHOD + "\n" + path + "\n" + hex(sha256(body))
X-Wursor-Timestamp: unix seconds
X-Wursor-Signature: hex(HMAC-SHA256(hmac_secret, canonical))
Authorization: Bearer <read_token|deploy_token>
- Reject if
|now - timestamp| > 60. - Reject if signature missing or
hash_equalsfails. hmac_secretis 256-bit, issued at redeem, stored on the plugin encrypted with the site salt (AUTH_KEY+AUTH_SALT). Not the same bytes as either token.- Body hash is over the raw bytes. Empty body is SHA-256 of
"".
Transport
- Plugin REST namespace:
/wp-json/wursor/v1/. - Plugin refuses non-HTTPS callbacks except
WP_ENVIRONMENT_TYPE === 'local'. - Wursor never puts tokens in URLs, logs, or SSE payloads.
Threats
| ID | Threat | Mitigation | Residual |
|---|---|---|---|
| T1 | Attacker guesses pairing codes | 8+ charset, 5-try lockout, 5-min TTL | Online brute force is ~5 guesses / 5 min / code |
| T2 | Pairing code leaked (screenshot, chat) | TTL + single use + requires wp-admin to paste | Anyone with the code and wp-admin wins until expiry |
| T3 | Attacker redeems victim's code onto attacker site | After redeem, Wursor shows the bound site_url and requires an explicit “this is my site” confirm before the site is usable |
User who confirms a foreign URL is connected to it |
| T4 | Bearer token in a URL / access log / Referer | Tokens only in Authorization. Tests fail if any helper puts them in a query |
Operator error in a future client |
| T5 | Stolen request replayed | HMAC over timestamp+method+path+body; 60s skew window | Replay inside the window if the request was captured |
| T6 | Stolen read token used to deploy |
Scoped tokens; deploy routes require deploy |
Read token still exfiltrates site-info |
| T7 | Plugin DB dump / filesystem copy | Plugin stores hashes + encrypted hmac_secret, not raw tokens | Wursor-side ciphertext leak still lets us call the plugin until rotation |
| T8 | MITM on HTTP | HTTPS required except local | Mis-set WP_ENVIRONMENT_TYPE on a public HTTP site |
| T9 | CSRF in the browser against plugin REST | Bearer + HMAC. No cookie auth for /wursor/v1/ |
None if those headers stay required |
| T10 | Timing leak on token compare | hash_equals only |
— |
Out of scope for this spike (handled elsewhere): stolen wp-admin session, compromised host, malicious plugin already on the site.
Sprint 2 tests (this note is the spec)
plugin/__tests__/test-auth.php
test_pairing_code_is_at_least_eight_alnum—^[A-Z0-9]{8,}$test_pairing_code_expires_after_five_minutes—advance_clock(301)→ redeem falsetest_pairing_code_valid_at_four_minutes_fifty_nine—advance_clock(299)→ redeem truetest_locks_out_after_five_failed_attempts— five bad redeems →is_locked_out()test_lockout_rejects_even_the_correct_codetest_successful_redeem_cannot_be_replayedtest_read_token_hash_is_stored_not_plaintexttest_read_token_forbidden_on_deploy_route→ 403test_deploy_token_allowed_on_site_infotest_hmac_rejects_stale_timestamp— timestamp older than 60stest_hmac_rejects_tampered_bodytest_hmac_rejects_missing_signaturetest_verify_uses_hash_equalstest_rotated_tokens_invalidate_old_hashes
api/__tests__/services/plugin-client.test.ts
- signs every request with timestamp + HMAC
- sends token in
Authorization, never in the URL - maps 401 to
Authentication failed - refuses to construct a client with an
http://site URL outside local
api/__tests__/routes/sites-pair.test.ts
- pair requires a session
- redeem binds
site_urland returns tokens once - second redeem of the same code fails
- site is not
connecteduntil the user confirms the shown URL (T3)
Decision
- Context: plugin can own the live site; old 6-char sketch had no TTL, lockout, HMAC, or scopes.
- Options: plugin-generated code (TV pairing) vs Wursor-generated code (PRD).
- Chosen: Wursor-generated, pasted into the plugin, HMAC + scoped tokens as above.
- Rejected: plugin-local generate/redeem (cannot bind to account first; IMPLEMENTATION sketch). Tokens in query strings. Single unscope token.
- Reverted later?