{
  "slug": "bw-lead-ai",
  "name": "BW Lead Attribution Intelligence",
  "version": "1.4.1",
  "download_url": "https://plugins.bowden.works/wp-content/uploads/plugin-updates/bw-lead-ai-1.4.1.zip",
  "download_hash": "sha256:47cbd86d026b7754ba53463d30b2fa88187eac32158f3edb309a010c920108b7",
  "download_size": 273160,
  "requires": "6.0",
  "tested": "",
  "requires_php": "7.4",
  "last_updated": "2026-08-06",
  "homepage": "https://plugins.bowden.works/bw-lead-ai/",
  "author": "Bowden Works",
  "description": "Capture traffic source, attribute it to every lead, and understand where your leads are coming from.",
  "changelog": "## [1.4.1] - 2026-08-06\n\n### Security\n- **The handoff CORS allow-list was not actually restricting anything.** Any origin\n  received `Access-Control-Allow-Origin` — plus `Access-Control-Allow-Credentials:\n  true` — regardless of the configured allow-list.\n\n  The cause was WordPress core, not the allow-list logic. Core's\n  `rest_send_cors_headers()` echoes **any** `Origin` back with credentials enabled,\n  and it is registered on the same `rest_pre_serve_request` hook the plugin uses. The\n  plugin only ever *added* headers for permitted origins; it never removed core's\n  permissive ones for an origin it rejected. So a rejected origin was declined by the\n  plugin and then allowed by core anyway.\n\n  This was invisible from an allow-listed origin — the response looked exactly right.\n  It only shows up when probing with a rejected origin, where core's broader\n  `Access-Control-Allow-Headers` value is the giveaway.\n\n  Fixed by removing core's CORS handler for the handoff namespace only\n  (`rest_pre_dispatch`), so the plugin is the sole source of CORS headers on these\n  routes. Every other REST route keeps core's behaviour untouched. A rejected origin\n  now receives no CORS headers at all. As a backstop, the handler also runs at a later\n  priority and explicitly strips any permissive headers another plugin may have set.\n\n- **`Access-Control-Allow-Credentials` is no longer advertised at all**, on any\n  origin. Nothing in the design uses credentials — the destination script fetches with\n  them omitted — and advertising them widened what a page on an allow-listed origin\n  could do with these routes. Core sets it unconditionally, so it is now removed\n  explicitly.\n\n  **Impact was bounded and remains so.** These routes are useless without a token, and\n  tokens are 128-bit from a CSPRNG, stored hashed, single-use for the claim, and\n  short-lived. CORS was always defence in depth rather than the control — as the class\n  docblock says. The problem was that the defence the code documented was not actually\n  present.\n\n### Added\n- **`tests/cors-check.sh`** — 13 assertions covering the above: a rejected origin gets\n  no grant, an allow-listed origin gets exactly its own origin and never a wildcard,\n  credentials are never advertised, preflight behaves, and a request with no `Origin`\n  gets nothing. Exits non-zero on failure. Validated by reproducing the 1.4.0 failure\n  mode, where 7 of the 13 fail.\n\n### Fixed\n- **Documentation gave the wrong journey-viewer URL.** `docs/HANDOFF-PLAN.md` showed\n  `admin.php?page=bw-lead-ai-journey`, but the page is a hidden submenu of Settings and\n  `BW_Lead_AI_Handoff_Admin::viewer_url()` correctly builds\n  `options-general.php?page=bw-lead-ai-journey`. Anyone wiring a destination from the\n  document rather than the code would have written dead links into every stored record.\n  The Handoff settings tab always printed the correct URL; only the plan document was\n  wrong. It now says so, and points at the settings tab as the authoritative source."
}
