Skip to content

fix(leads): authenticate desktop Turnstile bypass#3491

Open
lspassos1 wants to merge 5 commits into
koala73:mainfrom
lspassos1:security/desktop-lead-auth
Open

fix(leads): authenticate desktop Turnstile bypass#3491
lspassos1 wants to merge 5 commits into
koala73:mainfrom
lspassos1:security/desktop-lead-auth

Conversation

@lspassos1
Copy link
Copy Markdown
Collaborator

Summary

Authenticates the desktop lead-capture Turnstile bypass with an HMAC-SHA256 shared-secret signature. Desktop requests keep their no-Turnstile path, but only when the sidecar signs the canonical payload and the server verifies a fresh timestamped signature.

cc @koala73

Closes #3252

Type of change

  • Bug fix
  • CI / Build / Infrastructure
  • Documentation

Affected areas

  • Desktop app (Tauri)
  • API endpoints (/api/*)
  • Other: lead capture security and docs

Root cause

Desktop signup requests used source: desktop-settings to bypass Turnstile because the desktop shell has no captcha surface. Without request authentication, a caller could spoof that source and reach the bypass path.

Changes

  • Add desktop auth headers and HMAC verification to registerInterest.
  • Canonicalize only string payload fields before signing/verifying.
  • Fail closed when WM_DESKTOP_SHARED_SECRET is missing unless WM_DESKTOP_AUTH_ALLOW_LEGACY=true is explicitly set.
  • Have the sidecar force source: desktop-settings, sign the cloud fallback request, and strip stale/framing headers before forwarding the rewritten JSON body.
  • Return a real Response from the sidecar HTTPS fallback path so the outer server can safely read arrayBuffer().
  • Document the required desktop signature headers and rollout behavior.
  • Add focused server and sidecar regression tests.

Validation

  • npx tsx --test tests/register-interest-desktop-auth.test.mjs
  • node --test --test-name-pattern "signs desktop register-interest" src-tauri/sidecar/local-api-server.test.mjs
  • npm run typecheck:api
  • npx biome lint server/worldmonitor/leads/v1/register-interest.ts src-tauri/sidecar/local-api-server.mjs tests/register-interest-desktop-auth.test.mjs src-tauri/sidecar/local-api-server.test.mjs
  • git diff --check
  • Fork PR: fix(leads): authenticate desktop Turnstile bypass lspassos1/WD#28

Risk

Moderate. Deployments must set WM_DESKTOP_SHARED_SECRET before unsigned desktop requests are rejected. WM_DESKTOP_AUTH_ALLOW_LEGACY=true remains available as an explicit temporary rollout fallback for older builds.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

@lspassos1 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR closes the unauthenticated desktop Turnstile bypass by adding HMAC-SHA256 signed request authentication: the sidecar signs outgoing register-interest requests with a shared key and timestamp, and the server verifies them before allowing the Turnstile skip. CORS headers, docs, and regression tests are included.

  • P1 – Legacy bypass overrides configured signing key silently: when both the legacy rollout flag and the shared signing key are active simultaneously, unsigned requests pass regardless of the key; the warning does not surface that the key is being skipped, creating false operator confidence.
  • P2 – Missing User-Agent in proxyRegisterInterestToCloud: violates the AGENTS.md "always include User-Agent in server-side fetch" convention.
  • P2 – timingSafeStringEqual early-returns on length mismatch: not exploitable here (both strings are always 71 chars post-regex), but the guard undermines the timing-safe contract for future callers.

Confidence Score: 3/5

Merge-safe after resolving the P1 warning gap; deploying as-is risks operators misreading the security posture during rollout.

One P1 security logic issue (legacy bypass silently overrides configured HMAC key with no warning) plus two P2 style findings. The P1 doesn't allow bypassing the rate limit but it does completely nullify the HMAC during the rollout window, which is the primary security goal of this PR.

server/worldmonitor/leads/v1/register-interest.ts — specifically the legacy bypass ordering and warning in verifyDesktopAuth

Security Review

  • Authentication bypass (P1)verifyDesktopAuth (register-interest.ts line 98): legacy path fires before the signing-key check; with ALLOW_LEGACY=true + signing key both set, unsigned requests bypass HMAC entirely with no operator-visible warning.
  • Timing oracle (P2)timingSafeStringEqual (register-interest.ts line 76): early return on length mismatch is not exploitable given the fixed-length strings but violates the timing-safe contract.
  • No hardcoded credentials or injection vectors were identified in the changed files.

Important Files Changed

Filename Overview
server/worldmonitor/leads/v1/register-interest.ts Core security change: adds HMAC-SHA256 verification for the desktop Turnstile bypass; contains a P1 issue where the legacy bypass fires before the signing-key check, allowing unsigned requests through even when the key is configured
src-tauri/sidecar/local-api-server.mjs Adds proxyRegisterInterestToCloud to sign outgoing requests with HMAC and strip stale headers; upgrades fetchWithTimeout HTTPS path to return a real Response; missing explicit User-Agent header per AGENTS.md convention
tests/register-interest-desktop-auth.test.mjs New server-side regression tests covering rejection, stale timestamps, tampering, canonicalization, legacy mode, and valid-signature happy path — good breadth
src-tauri/sidecar/local-api-server.test.mjs Adds sidecar integration test verifying that stale client headers are stripped, source is overridden to desktop-settings, and a fresh valid HMAC signature is produced before forwarding
api/_cors.js Adds the two new desktop auth headers to Access-Control-Allow-Headers in both CORS helper functions
server/cors.ts Mirrors the CORS header update for the TypeScript server-side CORS helper
docs/api-platform.mdx Documents the new desktop signature headers, HMAC input format, and rollout behavior for register-interest
docs/usage-rate-limits.mdx Updates rate-limit table to reflect that the desktop bypass now requires a signed HMAC rather than being an unchecked bypass

Sequence Diagram

sequenceDiagram
    participant DA as Desktop App (Tauri)
    participant SC as Local Sidecar
    participant CA as Cloud API

    DA->>SC: POST /api/register-interest
    Note over SC: No CONVEX_URL → proxy to cloud
    SC->>SC: Parse body, force source=desktop-settings
    SC->>SC: Read shared signing key
    alt Key configured
        SC->>SC: timestamp = Date.now()
        SC->>SC: HMAC-SHA256(key, timestamp + newline + canonicalJSON)
        SC->>SC: Set Desktop-Timestamp + Desktop-Signature headers
    end
    SC->>SC: Strip stale/framing headers
    SC->>CA: POST with signed headers + rewritten body

    CA->>CA: isDesktopSource = true → verifyDesktopAuth()
    alt No headers + ALLOW_LEGACY=true
        CA-->>SC: Legacy bypass accepted (rate-limited 2/hr)
    else Key missing and no legacy
        CA-->>SC: 403 Desktop authentication failed
    else Headers present + key configured
        CA->>CA: Validate timestamp within 5-min window
        CA->>CA: Validate signature format
        CA->>CA: Compute expected HMAC, timingSafeEqual()
        alt HMAC matches
            CA->>CA: Check scoped rate limit (2/hr per IP)
            CA-->>SC: 200 registered
        else HMAC mismatch or stale
            CA-->>SC: 403 Desktop authentication failed
        end
    end
    SC-->>DA: Forward cloud response
Loading

Reviews (1): Last reviewed commit: "fix(leads): fail closed for unsigned des..." | Re-trigger Greptile

Comment on lines +98 to +105
if (!timestamp && !signature && process.env[DESKTOP_AUTH_ALLOW_LEGACY_ENV] === 'true') {
console.warn(`[register-interest] ${DESKTOP_AUTH_ALLOW_LEGACY_ENV}=true; accepting unsigned legacy desktop bypass`);
return;
}

if (!secret) {
console.warn(`[register-interest] ${DESKTOP_AUTH_SECRET_ENV} not set; rejecting desktop bypass`);
throw new ApiError(403, 'Desktop authentication failed', '');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Legacy bypass overrides the HMAC guard without warning

The legacy-mode check fires before the HMAC signing key is read. When the ALLOW_LEGACY env var and the shared signing key are both set simultaneously, any unsigned desktop request (omitting both auth headers entirely) is accepted through the legacy path — the HMAC provides zero protection for that traffic class. An attacker who knows the legacy flag is active can always bypass authentication by simply not sending the two desktop auth headers.

The console.warn only says "accepting unsigned legacy desktop bypass" and gives no indication that a configured signing key is being skipped, making this operationally invisible. The warning should surface whether a key is present, so operators can confirm when the rollout safety valve is still open despite a key having been deployed.

Comment on lines +461 to +480
const headers = toHeaders(req.headers, { stripOrigin: true });
headers.delete('Authorization');
headers.delete('If-None-Match');
headers.delete('If-Modified-Since');
headers.delete('Transfer-Encoding');
headers.delete('Content-Encoding');
headers.delete('Connection');
headers.delete('Expect');
headers.delete(DESKTOP_AUTH_TIMESTAMP_HEADER);
headers.delete(DESKTOP_AUTH_SIGNATURE_HEADER);
headers.set('Origin', 'https://worldmonitor.app');
headers.set('Content-Type', 'application/json');
headers.set('Content-Length', String(Buffer.byteLength(body)));

const secret = process.env[DESKTOP_AUTH_SECRET_ENV];
if (secret) {
const timestamp = String(Date.now());
headers.set(DESKTOP_AUTH_TIMESTAMP_HEADER, timestamp);
headers.set(DESKTOP_AUTH_SIGNATURE_HEADER, signDesktopAuthPayload(secret, timestamp, normalizedPayload));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing User-Agent header in cloud forwarding request

Per the project's AGENTS.md convention, server-side fetch calls must always include a User-Agent header. proxyRegisterInterestToCloud builds its outgoing headers from the desktop client's request via toHeaders, which may or may not carry a User-Agent. If the desktop Tauri shell doesn't populate one, the cloud receives an identityless request.

headers.set('Origin', 'https://worldmonitor.app');
headers.set('Content-Type', 'application/json');
headers.set('Content-Length', String(Buffer.byteLength(body)));
headers.set('User-Agent', 'WorldMonitor-Desktop-Sidecar/1.0'); // add this

Context Used: AGENTS.md (source)

Comment on lines +76 to +83
function timingSafeStringEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 timingSafeStringEqual leaks length via early return

The guard if (a.length !== b.length) return false short-circuits before the constant-time loop, technically making length a timing oracle. In practice this isn't exploitable here — the regex ^sha256=[a-f0-9]{64}$ on supplied and HMAC's fixed output guarantee both strings are always 71 chars — but the implementation can mislead future callers into assuming it is fully timing-safe.

Since the environment uses crypto.subtle (edge runtime), consider an unconditional length-equalization approach:

function timingSafeStringEqual(a: string, b: string): boolean {
  const maxLen = Math.max(a.length, b.length);
  let diff = a.length !== b.length ? 1 : 0;
  for (let i = 0; i < maxLen; i += 1) {
    diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
  }
  return diff === 0;
}

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0c37e7932

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +476 to +480
const secret = process.env[DESKTOP_AUTH_SECRET_ENV];
if (secret) {
const timestamp = String(Date.now());
headers.set(DESKTOP_AUTH_TIMESTAMP_HEADER, timestamp);
headers.set(DESKTOP_AUTH_SIGNATURE_HEADER, signDesktopAuthPayload(secret, timestamp, normalizedPayload));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail fast when desktop signing secret is unavailable

proxyRegisterInterestToCloud rewrites every /api/register-interest request to source: "desktop-settings" but only attaches HMAC headers when WM_DESKTOP_SHARED_SECRET is present. In the packaged desktop flow, that secret is not currently part of the sidecar secret plumbing (SUPPORTED_SECRET_KEYS in src-tauri/src/main.rs and the sidecar env allowlists do not include it), so this branch forwards unsigned desktop-bypass requests and they are rejected with 403 once cloud enables WM_DESKTOP_SHARED_SECRET without legacy mode, effectively breaking desktop signup. This path should either guarantee the secret is injected or return a local configuration error instead of forwarding unsigned bypass traffic.

Useful? React with 👍 / 👎.

@lspassos1
Copy link
Copy Markdown
Collaborator Author

Follow-up pushed in c0c37e79 for the desktop auth review.

Changes:

  • Desktop requests now fail closed whenever WM_DESKTOP_SHARED_SECRET is configured, even if WM_DESKTOP_AUTH_ALLOW_LEGACY=true.
  • Legacy unsigned desktop requests are accepted only when the rollout flag is enabled and the shared secret is unset.
  • Added a fixed User-Agent on the desktop sidecar cloud fallback request.
  • Updated the signature comparison to avoid returning early on length mismatch.
  • Added regression coverage for the secret + legacy flag case.

Validation:

  • npx tsx --test tests/register-interest-desktop-auth.test.mjs
  • node --test --test-name-pattern "signs desktop register-interest" src-tauri/sidecar/local-api-server.test.mjs
  • npm run typecheck:api
  • git diff --check
  • npx biome lint ... completed with existing sidecar warnings only.

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.

Lead capture: authenticate desktop Turnstile bypass with signed shared secret

1 participant