From e3cbe4059ced81c3aa331376a27be6e5fd4428e0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:35:52 -0400 Subject: [PATCH 01/20] docs(hover): headless-browser auth design + ADR 0001 (defeat Imperva ABP) Replace cold-HTTP login with a real-Chrome (go-rod) session driver that runs Imperva's JS sensor + mints clearance; full-browser flow (TLS/JS/cookie consistency); system/cached/container Chrome; stealth. Login-only optimization deferred to an empirical scope test with the verified test account. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0001-real-browser-auth-for-imperva.md | 28 +++++ ...2026-05-30-headless-browser-auth-design.md | 110 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 decisions/0001-real-browser-auth-for-imperva.md create mode 100644 docs/plans/2026-05-30-headless-browser-auth-design.md diff --git a/decisions/0001-real-browser-auth-for-imperva.md b/decisions/0001-real-browser-auth-for-imperva.md new file mode 100644 index 0000000..705b208 --- /dev/null +++ b/decisions/0001-real-browser-auth-for-imperva.md @@ -0,0 +1,28 @@ +# 0001. Drive Hover auth with a real browser (go-rod) to defeat Imperva ABP + +**Status:** Accepted +**Date:** 2026-05-30 +**Decision-makers:** codingsloth@pm.me (approved), Claude (Opus 4.8) +**Related:** docs/plans/2026-05-30-headless-browser-auth-design.md + +## Context + +Hover has no public API; the plugin scrapes the React signin (`/signin/auth.json`). Hover is behind **Imperva Advanced Bot Protection** (ex-Distil): a JS sensor (`/c`) mints clearance cookies (`__uzma/b/c/d/e`, `uzmx`) that the auth endpoint requires. A cold Go `http.Client` never runs the sensor → Imperva returns a generic 401 even with valid credentials (root-caused live 2026-05-30). No header/token tweak can pass it: the clearance is a JS-computed, server-validated, rotating fingerprint, additionally backed by TLS/JA3 + behavioral signals. The prior HTTP login never actually worked against live Hover — only against the test stub. + +## Decision + +We will authenticate by **driving a real Chrome via go-rod** (CDP) so Imperva's JS executes and mints clearance, performing the **full** Hover flow in-browser (login + API calls) to keep TLS/JS/cookies consistent. + +Alternatives rejected: +- **Keep HTTP-scrape** — cannot pass Imperva; proven non-functional against live Hover. +- **Managed solver services (Scrapfly/ZenRows/2captcha)** — would route Hover *login credentials* through a third party; unacceptable for auth. +- **Python tooling (nodriver/zendriver/SeleniumBase-UC)** — the 2026 SOTA, but wrong language for a Go gRPC plugin; would bolt a Python runtime onto the plugin. +- **Reverse-engineer the Imperva sensor** in Go — fragile, breaks on every Imperva update, unmaintainable, more ToS-hostile than driving a real browser. + +## Consequences + +- Defeats Imperva on a **best-effort** basis (arms race): works today; Imperva updates may break it. Mitigated by full-browser (max longevity) + periodic re-evaluation of maintained solutions. +- Adds a **Chrome runtime dependency** (system / go-rod-cached / container) — cannot compile into the Go binary; heavier CI (download/cache or container; tens of seconds per login vs the old sub-second HTTP). +- ToS gray area (passing bot-protection); shipped best-effort with a README disclaimer; public MIT plugin, documented not hidden. +- The `hoverclient` interface + gRPC surface are unchanged — drivers/provider untouched; the change is internal + revertible by version pin (rollback = Hover automation disabled, since v0.4.2 can't auth live). +- Negative: go-rod's CDP + stealth has known gaps vs Imperva's behavioral/ML; viability is gated by a live login test before the full build. diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md new file mode 100644 index 0000000..ddfe7ef --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -0,0 +1,110 @@ +# Headless-browser Hover auth (defeat Imperva ABP) — Design + +**Date:** 2026-05-30 +**Status:** Design (autonomous pipeline; user-approved direction) +**Guidance:** `/Users/jon/workspace/docs/design-guidance.md` (workspace rev 4) +**Repo:** workflow-plugin-hover + +## Problem + +Hover has no public API; `pkg/hoverclient` scrapes the React signin flow (`POST /signin/auth.json`). Hover sits behind **Imperva Advanced Bot Protection** (ex-Distil): a JS *sensor* POSTed to `/c` mints clearance cookies (`__uzma/b/c/d/e`, `uzmx`, `__ss*`) that `auth.json` then requires. A cold Go `http.Client` never runs the sensor → Imperva rejects it → generic `401 "Invalid username or password"` even with valid creds (root-caused live 2026-05-30 via playwright: dummy-cred probe returned the normal JSON error; real browser login from the same IP succeeds; the `__uzm*`+`/c` handshake is the difference). Token/header fixes (v0.4.1/v0.4.2) were correct hardening but cannot pass Imperva — the clearance is a JS-computed, server-validated, rotating fingerprint. **The HTTP-scrape login never worked against live Imperva-protected Hover; it only ever passed the test stub.** + +## Decision (approved) + +Replace the cold-HTTP login with a **real-Chrome session driver** (CDP via **go-rod**) that runs Imperva's JS, mints clearance, and drives the **full** Hover flow in-browser. + +### Architecture + +`hoverclient.Client.Login()` (+ the API methods) swap their HTTP impl for a go-rod browser session behind the **unchanged** `hoverclient` interface — so `internal/provider.go` + the `infra.dns` / `infra.dns_delegation` drivers are untouched. + +``` +Login(): launch/attach Chrome → goto /signin (Imperva sensor runs, clearance minted) + → type username/password (human-like delays) → submit auth.json + → if need_2fa: type TOTP code → auth2.json → authenticated control panel +ListDomains()/SetNameservers(): performed IN-BROWSER (navigate control-panel pages / + in-page fetch with the live clearance), NOT via a separate Go http.Client. +``` + +### Browser scope: full-browser (default) + +All Hover requests run inside Chrome, not just login. **Why:** TLS/JA3 is a documented Imperva signal; handing cookies to Go's `http.Client` for the API calls exposes a non-Chrome TLS fingerprint — a tell even *with* valid cookies. In-browser keeps TLS + JS + cookies consistent end-to-end. + +**Deferred empirical confirmation (plan's FIRST validation task):** with the verified test account, log in via the browser, then attempt a same-session `ListDomains` via plain Go HTTP reusing the clearance cookies. If Imperva does NOT re-challenge (clears the session, not per-request), the login-only optimization (browser→cookies→HTTP) is viable + lighter; adopt it. Until proven, full-browser is the safe default. + +### Chrome acquisition (resolution order) + +1. **System/PATH Chrome** if present (`google-chrome`, `chromium`, `$ROD_BROWSER_PATH`). +2. Else **go-rod launcher auto-downloads + caches** a pinned Chromium (`~/.cache/rod`). +3. **Container image** with Chrome baked, for CI (the gocodealone-dns import workflow runs in it). + +The browser cannot compile into the Go binary (~150MB); this is the "bundle what we can + external OK" middle path. A `HOVER_BROWSER_PATH` / `HOVER_BROWSER_DOWNLOAD=false` config lets operators pin behavior. + +### Stealth (must not read as automated) + +- New-headless (`--headless=new`) or headful; never old headless-shell. +- Strip `navigator.webdriver`; consistent UA + `sec-ch-ua` client-hints matching the launched Chrome's real version (no UA/version skew). +- Human-like input: per-key delays, a mouse move/click into fields before typing, small randomized waits. +- Persistent profile / cookie jar so the Imperva clearance survives across calls within `sessionStaleAfter`. +- go-rod/stealth has known gaps (webdriver leaks on new pages); apply manual page-init JS patches on top. + +### Error handling / failure modes + +- **Chrome missing + download disabled** → clear error: "no Chrome found; install Chrome or set HOVER_BROWSER_DOWNLOAD=true". +- **Imperva block / challenge page** (not the normal login form) → detect (challenge HTML / unexpected redirect / persistent 401) → return a typed `ErrBotChallenge` with guidance, NOT a misleading auth error. +- **TOTP missing when 2FA required** → existing behavior (clear error). +- **Browser launch/crash** → bounded retries (1 relaunch), then fail with the launch error. +- **Slow Imperva sensor** → explicit waits for clearance cookie presence before submitting, with timeout. + +## Global Design Guidance + +Source: `docs/design-guidance.md` (rev 4) + +| guidance | response | +|---|---| +| Primary Go, stdlib-first, deps justified | go-rod is a justified external dep (only viable Go CDP driver with stealth ecosystem); no alternative passes Imperva | +| No new standalone binaries | extends the existing plugin; browser is a runtime dependency, not a new wfctl binary | +| Plugin contracts unchanged | `hoverclient` interface + gRPC surface unchanged; swap is internal | +| Secrets never logged | creds typed into a local browser; never logged; `(creds redacted)` preserved; profile dir is local + gitignored | +| Goreleaser + release workflow | new minor release (v0.5.0 — behavioral change); plugin.json minEngineVersion unchanged | +| Cross-driver parity / e2e via real consumer | validate via the gocodealone-dns import workflow (real Hover) + the test account | + +## Security Review + +- **Auth/secrets**: Hover username/password/TOTP typed into a locally-launched Chrome on the runner; never transmitted to any third party (no managed solver). Profile/cookie cache is local + gitignored; treat as sensitive (clears on stale). +- **Trust boundary / new deps**: adds go-rod + a Chrome binary (downloaded-and-checksummed by go-rod, or system). Chrome is a large attack surface but standard. Pin the Chromium revision; rely on go-rod's checksum on download. +- **Abuse / ToS**: deliberately passes Imperva bot-protection — Hover ToS gray area. Shipped best-effort with a README disclaimer; Hover has no API alternative. Public MIT plugin: documented, not hidden. +- **Least privilege**: browser runs sandboxed by default; `--no-sandbox` only where the CI container requires it (documented). + +## Infrastructure Impact + +- **CI weight**: the import workflow gains a Chrome dependency — either a cached download (~150MB first run, cached after) or a container image with Chrome. Slower than the old HTTP path (seconds → tens of seconds per login). Self-hosted Linux runner must allow Chrome (deps/sandbox). +- **No cloud resources created**; read-only DNS enumeration + (migration) NS writes unchanged in effect. +- **Rollback**: see below. + +## Multi-Component Validation + +- **Plugin + real Hover**: the gocodealone-dns `import-dns.yml` run against live Hover (Imperva) is the end-to-end proof — `imported N infra.dns zones via provider "hover"` instead of the 401. +- **Test account**: `hover-dns-test@gocodealone.com` (recorded in gocodealone-dns/.hover-test-account.local.md) for repeatable login validation without risking the production Hover account's lockout. +- Not mock-only: go-rod tests can stub a local server for unit logic, but the Imperva-pass is validated against real Hover. + +## Assumptions + +1. **A real Chrome (go-rod, stealthed) can pass Imperva for Hover login.** Most fragile. Evidence: playwright (real Chromium) passed Imperva for signup; go-rod drives the same engine. Risk: go-rod's CDP/stealth gaps vs Imperva behavioral/ML. **Gated by the plan's first task** (live login with the test account); if go-rod can't pass where playwright did, reconsider driver (e.g. a more stealth-focused approach) before building the rest. +2. **The verified test account is available** for validation. Currently pending email verification (catch-all lag); not a design blocker, but a validation-task dependency. +3. **Imperva clearance is session-scoped** (one clearance covers the session) — drives whether login-only is viable; tested in the deferred scope task. Full-browser is safe regardless. +4. **go-rod's launcher can fetch a working Chromium on the runner OS** (Linux X64); else system Chrome / container. +5. **Hover's signin flow shape is stable** (`/signin/auth.json` + `/signin/auth2.json` + TOTP); already used by the current client. + +## Rollback + +Runtime-affecting (auth path + new browser dependency + CI). Rollback path: +- Revert to the previous release (v0.4.2) — the HTTP-only client. NOTE: v0.4.2 cannot actually authenticate against live Imperva, so rollback = "Hover automation disabled" not "working old behavior". The gocodealone-dns hover pin reverts to v0.4.2 + Hover import returns to known-manual (workflow already isolates Hover failure: continue-on-error + honest-red). +- Feature is additive behind the unchanged interface; reverting the plugin version is the rollback. + +## Re-evaluation cadence + +Per user: re-check for maintained Imperva-bypass solutions periodically (next: ~2026-05-30 search done — no maintained Go lib; revisit if go-rod stealth proves insufficient or breaks). Record findings in the retro. + +## ADR + +- **ADR 0001** — Drive Hover auth with a real browser (go-rod) to defeat Imperva ABP; reject HTTP-scrape (cannot pass), managed solvers (cred-leak), and Python tooling (wrong language). From ea3ddd301b86d368226982bbfacb11aa4c88b8f1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:41:23 -0400 Subject: [PATCH 02/20] docs(hover): harden browser auth design review --- ...rowser-auth-design.adversarial-review-1.md | 30 +++++++++++++++++++ ...2026-05-30-headless-browser-auth-design.md | 11 ++++--- 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-05-30-headless-browser-auth-design.adversarial-review-1.md diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.adversarial-review-1.md b/docs/plans/2026-05-30-headless-browser-auth-design.adversarial-review-1.md new file mode 100644 index 0000000..f1609e7 --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth-design.adversarial-review-1.md @@ -0,0 +1,30 @@ +# Headless-browser Hover auth — Adversarial Design Review 1 + +**Date:** 2026-05-30 +**Phase:** design +**Design:** `docs/plans/2026-05-30-headless-browser-auth-design.md` +**Status:** PASS after design corrections + +## Findings + +| sev | class | loc | issue | resolution | +|---|---|---|---|---| +| Critical | validation | `Multi-Component Validation` | Browser implementation could proceed against local stubs even if the verified Hover account or go-rod Imperva pass failed. | Design now requires live viability as the plan's first task and blocks stub-only implementation when the test account is unavailable. | +| Important | testability | `Architecture` | Replacing the HTTP path outright would destroy existing `httptest` coverage or force every unit test to launch Chrome. | Design now requires an internal execution-backend seam: production browser backend, tests may keep local HTTP backend. | +| Important | config | `Chrome acquisition` | Runtime knobs were named inconsistently and not clearly exposed at provider config/env level. | Design now requires `browser_path`, `browser_download`, `browser_headless`, `browser_profile_dir` plus `HOVER_*` env aliases and `ROD_BROWSER_PATH` compatibility. | +| Important | secrets | `Stealth` | Persistent browser profile could be created inside repo and accidentally committed; profile contains auth cookies. | Design now requires sensitive default under `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile` and gitignore coverage for local test paths. | +| Important | docs drift | `Multi-Component Validation` | README still documents obsolete CSRF form login, causing operators to debug the wrong failure mode. | Design now requires README update for browser auth, Chrome/runtime config, bot challenge behavior, and sensitive profile handling. | +| Minor | optimization | `Browser scope` | Browser-login + Go HTTP API reuse may be viable, but adopting it before evidence would reintroduce TLS/JA3 risk. | Design keeps full-browser as default and makes login-only optimization evidence-gated. | + +## Clean Checks + +| area | result | +|---|---| +| Public contract | unchanged `hoverclient` + provider surface preserved. | +| Ecosystem fit | Go plugin implementation; no Python harness or standalone demo tool. | +| Security posture | no managed CAPTCHA/solver service; secrets redacted; profile treated as sensitive state. | +| Rollback | rollback is honest: v0.4.2 disables live Hover automation, not a working fallback. | + +## Verdict + +PASS. Remaining risk is empirical, not design-structural: go-rod may still fail Imperva where Playwright succeeds. The implementation plan must make that the first executable gate and stop if it fails. diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index ddfe7ef..0bc4715 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -15,7 +15,7 @@ Replace the cold-HTTP login with a **real-Chrome session driver** (CDP via **go- ### Architecture -`hoverclient.Client.Login()` (+ the API methods) swap their HTTP impl for a go-rod browser session behind the **unchanged** `hoverclient` interface — so `internal/provider.go` + the `infra.dns` / `infra.dns_delegation` drivers are untouched. +`hoverclient.Client.Login()` (+ the API methods) swap their live Hover execution path for a go-rod browser session behind the **unchanged** `hoverclient` interface — so `internal/provider.go` + the `infra.dns` / `infra.dns_delegation` drivers are untouched. Preserve testability by introducing an internal execution-backend seam: production uses the browser backend, unit tests can keep the local `httptest` HTTP backend without launching Chrome. This is not a public contract. ``` Login(): launch/attach Chrome → goto /signin (Imperva sensor runs, clearance minted) @@ -33,11 +33,11 @@ All Hover requests run inside Chrome, not just login. **Why:** TLS/JA3 is a docu ### Chrome acquisition (resolution order) -1. **System/PATH Chrome** if present (`google-chrome`, `chromium`, `$ROD_BROWSER_PATH`). +1. **System/PATH Chrome** if present (`google-chrome`, `chromium`, `$HOVER_BROWSER_PATH`; also honor `$ROD_BROWSER_PATH` for rod compatibility). 2. Else **go-rod launcher auto-downloads + caches** a pinned Chromium (`~/.cache/rod`). 3. **Container image** with Chrome baked, for CI (the gocodealone-dns import workflow runs in it). -The browser cannot compile into the Go binary (~150MB); this is the "bundle what we can + external OK" middle path. A `HOVER_BROWSER_PATH` / `HOVER_BROWSER_DOWNLOAD=false` config lets operators pin behavior. +The browser cannot compile into the Go binary (~150MB); this is the "bundle what we can + external OK" middle path. Provider config/env must expose `browser_path`, `browser_download`, `browser_headless`, and `browser_profile_dir` (env aliases: `HOVER_BROWSER_PATH`, `HOVER_BROWSER_DOWNLOAD`, `HOVER_BROWSER_HEADLESS`, `HOVER_BROWSER_PROFILE_DIR`) so operators can pin behavior and keep profile state out of the repo. ### Stealth (must not read as automated) @@ -46,6 +46,7 @@ The browser cannot compile into the Go binary (~150MB); this is the "bundle what - Human-like input: per-key delays, a mouse move/click into fields before typing, small randomized waits. - Persistent profile / cookie jar so the Imperva clearance survives across calls within `sessionStaleAfter`. - go-rod/stealth has known gaps (webdriver leaks on new pages); apply manual page-init JS patches on top. +- Browser profile directory is sensitive session state; default under `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile`, not the repository. Add/keep gitignore coverage for any local profile path used during tests. ### Error handling / failure modes @@ -54,6 +55,7 @@ The browser cannot compile into the Go binary (~150MB); this is the "bundle what - **TOTP missing when 2FA required** → existing behavior (clear error). - **Browser launch/crash** → bounded retries (1 relaunch), then fail with the launch error. - **Slow Imperva sensor** → explicit waits for clearance cookie presence before submitting, with timeout. +- **Verified test account unavailable** → stop after the live viability task and mark the plan blocked; do not build an unproven browser driver against only stubs. ## Global Design Guidance @@ -66,7 +68,7 @@ Source: `docs/design-guidance.md` (rev 4) | Plugin contracts unchanged | `hoverclient` interface + gRPC surface unchanged; swap is internal | | Secrets never logged | creds typed into a local browser; never logged; `(creds redacted)` preserved; profile dir is local + gitignored | | Goreleaser + release workflow | new minor release (v0.5.0 — behavioral change); plugin.json minEngineVersion unchanged | -| Cross-driver parity / e2e via real consumer | validate via the gocodealone-dns import workflow (real Hover) + the test account | +| Cross-driver parity / e2e via real consumer | validate via the gocodealone-dns import workflow or equivalent local wfctl run using the real Hover provider + the test account | ## Security Review @@ -86,6 +88,7 @@ Source: `docs/design-guidance.md` (rev 4) - **Plugin + real Hover**: the gocodealone-dns `import-dns.yml` run against live Hover (Imperva) is the end-to-end proof — `imported N infra.dns zones via provider "hover"` instead of the 401. - **Test account**: `hover-dns-test@gocodealone.com` (recorded in gocodealone-dns/.hover-test-account.local.md) for repeatable login validation without risking the production Hover account's lockout. - Not mock-only: go-rod tests can stub a local server for unit logic, but the Imperva-pass is validated against real Hover. +- **Docs drift check**: README currently describes the old CSRF form login. The implementation plan must update it to the browser-driven auth path, Chrome/runtime config, bot-challenge behavior, and sensitive profile handling. ## Assumptions From 7a8e9e9edb9cbfd559df9d9f1787189d71ca7491 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:44:10 -0400 Subject: [PATCH 03/20] docs(hover): plan browser-backed auth implementation --- .../plans/2026-05-30-headless-browser-auth.md | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 docs/plans/2026-05-30-headless-browser-auth.md diff --git a/docs/plans/2026-05-30-headless-browser-auth.md b/docs/plans/2026-05-30-headless-browser-auth.md new file mode 100644 index 0000000..95dc4b7 --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth.md @@ -0,0 +1,455 @@ +# Headless-browser Hover auth Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. + +**Goal:** Replace Hover's broken cold-HTTP signin with a real Chrome/go-rod execution path that can pass Imperva and operate the Hover account portal through the existing Workflow plugin/provider contract. + +**Architecture:** Keep `hoverclient.Client`, `internal/provider.go`, and the IaC driver interfaces stable; add private backend/config seams so production uses a browser backend and tests may keep the current local HTTP backend. Validate live Hover first, then implement full-browser DNS/delegation calls, provider runtime options, docs, and release proof. + +**Tech Stack:** Go 1.26, go-rod CDP browser driver, existing Workflow IaC provider interfaces, existing `httptest` unit tests, opt-in live tests against Hover. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 6 +**Estimated Lines of Change:** ~1800 (informational; not enforced) + +**Out of scope:** +- Managed CAPTCHA/solver services or any third-party credential proxy. +- Python/Playwright harnesses, standalone tools, or non-Workflow demos. +- Public API/contract break for `hoverclient.Client`, plugin gRPC surface, or Workflow IaC resource types. +- New cloud resources or production DNS mutation outside explicit live-test/import validation. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Browser-backed Hover auth for Imperva-protected control panel | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/headless-browser-auth-2026-05-30T2030 | + +**Status:** Draft + +### Task 1: Live Browser Viability Gate + +**Files:** +- Create: `pkg/hoverclient/browser_options.go` +- Create: `pkg/hoverclient/browser_live_test.go` +- Modify: `go.mod` +- Modify: `go.sum` + +**Step 1: Write the opt-in live test** + +Create `TestLiveBrowserLoginAndHTTPReuseProbe` with these invariants: + +```go +func TestLiveBrowserLoginAndHTTPReuseProbe(t *testing.T) { + if os.Getenv("HOVER_LIVE_TEST") != "1" { + t.Skip("set HOVER_LIVE_TEST=1 to run live Hover browser auth probe") + } + creds := liveCredentialsFromEnv(t) + opts := liveBrowserOptionsFromEnv(t) + result, err := ProbeLiveBrowserAuth(context.Background(), creds, opts) + if err != nil { + t.Fatalf("live browser auth probe: %v", err) + } + if !result.LoginSucceeded { + t.Fatalf("login did not complete") + } + if len(result.ClearanceCookies) == 0 { + t.Fatalf("Imperva clearance cookies not observed") + } + t.Logf("go_http_reuse_viable=%t domains=%d clearance_cookies=%v", result.GoHTTPReuseViable, result.DomainCount, result.ClearanceCookieNames()) +} +``` + +`liveCredentialsFromEnv` reads `HOVER_USERNAME`, `HOVER_PASSWORD`, optional `HOVER_TOTP_SECRET`; it must not log secret values. If live env is absent while `HOVER_LIVE_TEST=1`, fail with a missing-env list. + +**Step 2: Run test to verify it fails before implementation** + +Run: `HOVER_LIVE_TEST=1 GOWORK=off go test ./pkg/hoverclient -run TestLiveBrowserLoginAndHTTPReuseProbe -count=1 -v` + +Expected: FAIL at compile time because `ProbeLiveBrowserAuth` / browser options do not exist, or FAIL with explicit missing live env. If verified Hover test-account credentials are unavailable, mark the locked plan blocked after this task; do not continue to stub-only browser implementation. + +**Step 3: Add minimal browser probe implementation** + +Add `BrowserOptions`, env parsing, and `ProbeLiveBrowserAuth`: + +```go +type BrowserOptions struct { + Path string + Download bool + Headless bool + ProfileDir string + Timeout time.Duration +} + +type LiveAuthProbeResult struct { + LoginSucceeded bool + ClearanceCookies []string + GoHTTPReuseViable bool + DomainCount int +} +``` + +Use go-rod to launch/attach Chrome, navigate to `https://www.hover.com/signin`, wait for Imperva clearance cookies (`__uzma`, `__uzmb`, `__uzmc`, `__uzmd`, `__uzme`, `uzmx`, `__ss*` prefixes), submit username/password and TOTP when required, and then probe whether a plain Go `http.Client` with copied cookies can call `GET /api/domains`. + +**Step 4: Run live viability** + +Run with the verified test-account environment loaded: + +```bash +HOVER_LIVE_TEST=1 \ +HOVER_BROWSER_HEADLESS=true \ +GOWORK=off go test ./pkg/hoverclient -run TestLiveBrowserLoginAndHTTPReuseProbe -count=1 -v +``` + +Expected: PASS with log containing `go_http_reuse_viable=` and `clearance_cookies=[...]`; no password/TOTP secret in output. If go-rod cannot pass Imperva while the same account works in a normal browser, stop and backport the design instead of continuing. + +**Step 5: Commit** + +```bash +git add go.mod go.sum pkg/hoverclient/browser_options.go pkg/hoverclient/browser_live_test.go +git commit -m "test(hoverclient): add live browser auth viability gate" +``` + +Rollback: revert commit; no runtime behavior changed unless the opt-in live test is run. + +### Task 2: Client Backend Seam and Provider Browser Config + +**Files:** +- Create: `pkg/hoverclient/backend.go` +- Create: `pkg/hoverclient/options.go` +- Modify: `pkg/hoverclient/client.go` +- Modify: `pkg/hoverclient/client_test.go` +- Modify: `internal/provider.go` +- Modify: `internal/provider_test.go` + +**Step 1: Write failing unit tests** + +Add tests for: +- `NewClient(creds, injectedHTTPClient)` uses the HTTP backend so existing `httptest` tests do not launch Chrome. +- `NewClient(creds, nil)` defaults to browser backend options. +- `NewClientWithOptions(creds, nil, ClientOptions{Browser: BrowserOptions{...}})` preserves explicit runtime config. +- Provider config maps `browser_path`, `browser_download`, `browser_headless`, `browser_profile_dir` plus `HOVER_BROWSER_*` env aliases into `hoverclient.ClientOptions`. + +Expected test names: + +```go +func TestNewClient_DefaultsToBrowserBackendWithoutInjectedHTTP(t *testing.T) +func TestNewClient_InjectedHTTPUsesHTTPBackendForTests(t *testing.T) +func TestInitialize_ParsesBrowserConfig(t *testing.T) +func TestInitialize_EnvBrowserConfigAliases(t *testing.T) +``` + +**Step 2: Run tests to verify failure** + +Run: `GOWORK=off go test ./pkg/hoverclient ./internal -run 'TestNewClient_|TestInitialize_.*Browser' -count=1 -v` + +Expected: FAIL because options/backend seam and provider parsing are absent. + +**Step 3: Implement backend seam** + +Add private interface: + +```go +type executionBackend interface { + Login(context.Context, *Client) error + ListDomains(context.Context, *Client) ([]Domain, error) + GetDomain(context.Context, *Client, string) (*Domain, error) + CreateRecord(context.Context, *Client, string, Record) (*Record, error) + UpdateRecord(context.Context, *Client, string, Record) (*Record, error) + DeleteRecord(context.Context, *Client, string, string) error + GetDomainDelegation(context.Context, *Client, string) (*DomainDelegation, error) + SetNameservers(context.Context, *Client, string, []string) error + Close() error +} +``` + +Move current HTTP logic behind `httpBackend`. `Client` keeps `NewClient(creds, httpClient)` for compatibility; `httpClient != nil` selects `httpBackend`. Production `httpClient == nil` selects `browserBackend` in Task 3. + +**Step 4: Implement provider config** + +Parse explicit provider config first, env aliases second, defaults last: +- `browser_path` / `HOVER_BROWSER_PATH` +- `browser_download` / `HOVER_BROWSER_DOWNLOAD` +- `browser_headless` / `HOVER_BROWSER_HEADLESS` +- `browser_profile_dir` / `HOVER_BROWSER_PROFILE_DIR` + +Do not log values. Default profile dir must be under `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile`. + +**Step 5: Run tests to verify pass** + +Run: `GOWORK=off go test ./pkg/hoverclient ./internal -run 'TestNewClient_|TestInitialize_.*Browser' -count=1 -v` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add pkg/hoverclient/backend.go pkg/hoverclient/options.go pkg/hoverclient/client.go pkg/hoverclient/client_test.go internal/provider.go internal/provider_test.go +git commit -m "refactor(hoverclient): add browser backend configuration seam" +``` + +Rollback: revert commit; re-run `GOWORK=off go test ./pkg/hoverclient ./internal`. + +### Task 3: Browser Backend Login, Stealth, and Typed Errors + +**Files:** +- Create: `pkg/hoverclient/browser_backend.go` +- Create: `pkg/hoverclient/browser_backend_test.go` +- Modify: `pkg/hoverclient/client.go` +- Modify: `pkg/hoverclient/backend.go` +- Modify: `.gitignore` + +**Step 1: Write failing browser-backend tests** + +Use local `httptest` pages exercised through go-rod where feasible. Tests: +- login form happy path sets `loggedAt` and handles no-MFA JSON completion. +- TOTP-required path posts a generated code and errors clearly when no `totp_secret`. +- challenge page detection returns typed `ErrBotChallenge`. +- Chrome missing with downloads disabled returns an actionable Chrome acquisition error. +- profile dir under repo is gitignored when local test override uses `.hover-browser-profile/`. + +Expected test names: + +```go +func TestBrowserBackend_LoginLocalNoMFA(t *testing.T) +func TestBrowserBackend_LoginLocalTOTPRequired(t *testing.T) +func TestBrowserBackend_LoginDetectsBotChallenge(t *testing.T) +func TestBrowserBackend_ChromeMissingDownloadDisabled(t *testing.T) +``` + +**Step 2: Run tests to verify failure** + +Run: `GOWORK=off go test ./pkg/hoverclient -run 'TestBrowserBackend_' -count=1 -v` + +Expected: FAIL because browser backend is absent. + +**Step 3: Implement browser launch and login** + +Implement `browserBackend` with: +- system Chrome lookup: `BrowserOptions.Path`, `HOVER_BROWSER_PATH`, `ROD_BROWSER_PATH`, `google-chrome`, `chromium`, Chrome app paths where appropriate. +- download control: if no Chrome found and `Download=false`, return `ErrChromeUnavailable`. +- launcher flags: new headless when `Headless=true`, persistent user data dir, no `--no-sandbox` unless explicitly required by environment/test hook. +- init scripts: remove `navigator.webdriver`, align UA/client hints with launched Chrome, set locale/timezone stable enough for Hover. +- human-like field interactions and waits for clearance cookie presence before auth submit. +- bounded relaunch retry once after browser crash. +- typed errors: `ErrBotChallenge`, `ErrChromeUnavailable`. + +**Step 4: Run tests to verify pass** + +Run: `GOWORK=off go test ./pkg/hoverclient -run 'TestBrowserBackend_' -count=1 -v` + +Expected: PASS. + +**Step 5: Run live login regression** + +Run with verified test-account environment: + +```bash +HOVER_LIVE_TEST=1 \ +HOVER_BROWSER_HEADLESS=true \ +GOWORK=off go test ./pkg/hoverclient -run TestLiveBrowserLoginAndHTTPReuseProbe -count=1 -v +``` + +Expected: PASS; no secret output; if `ErrBotChallenge`, stop and backport design. + +**Step 6: Commit** + +```bash +git add .gitignore pkg/hoverclient/browser_backend.go pkg/hoverclient/browser_backend_test.go pkg/hoverclient/client.go pkg/hoverclient/backend.go +git commit -m "feat(hoverclient): authenticate hover through chrome" +``` + +Rollback: revert commit; delete any local `.hover-browser-profile/`; re-run package tests. + +### Task 4: Full-Browser DNS and Delegation Operations + +**Files:** +- Modify: `pkg/hoverclient/browser_backend.go` +- Modify: `pkg/hoverclient/browser_backend_test.go` +- Modify: `pkg/hoverclient/client.go` +- Modify: `pkg/hoverclient/client_test.go` +- Modify: `internal/drivers/dns_test.go` +- Modify: `internal/drivers/delegation_test.go` + +**Step 1: Write failing API operation tests** + +Add tests that verify `Client` delegates these operations to the selected backend: +- `ListDomains` +- `GetDomain` +- `ListRecords` +- `CreateRecord` +- `UpdateRecord` +- `DeleteRecord` +- `GetDomainDelegation` +- `SetNameservers` + +For browser backend local tests, use in-page `fetch` against a local server and parse JSON from the page context so network requests originate from Chrome. Include CSRF meta extraction for `SetNameservers`. + +**Step 2: Run tests to verify failure** + +Run: `GOWORK=off go test ./pkg/hoverclient ./internal/drivers -run 'Test(Client|BrowserBackend|DNS|Delegation)' -count=1 -v` + +Expected: FAIL for browser backend API methods not implemented or not delegated. + +**Step 3: Implement full-browser operations** + +Move live production calls into browser context: +- Use `page.Eval` / in-page `fetch` for `/api/domains`, `/api/domains//dns`, `/api/dns`, `/api/dns/`, `/api/control_panel/domains/domain-`. +- Fetch CSRF token from `/control_panel/domain/` inside Chrome before nameserver PUT. +- Preserve current JSON structs/errors, including `ErrEmptyNameservers` and existing record parsing semantics. +- Keep `httpBackend` behavior for injected-HTTP tests. +- If Task 1 proved plain Go HTTP reuse viable, document the evidence in a comment and leave browser as default; do not switch default back to HTTP without amending the design. + +**Step 4: Run focused tests** + +Run: `GOWORK=off go test ./pkg/hoverclient ./internal/drivers -count=1 -v` + +Expected: PASS. + +**Step 5: Run all unit tests** + +Run: `GOWORK=off go test ./... -count=1` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add pkg/hoverclient/browser_backend.go pkg/hoverclient/browser_backend_test.go pkg/hoverclient/client.go pkg/hoverclient/client_test.go internal/drivers/dns_test.go internal/drivers/delegation_test.go +git commit -m "feat(hoverclient): execute hover dns operations in browser" +``` + +Rollback: revert commit; re-run `GOWORK=off go test ./... -count=1`. + +### Task 5: Workflow Plugin Runtime Validation With Real Consumer + +**Files:** +- Modify: `internal/iacserver_live_test.go` +- Modify: `cmd/workflow-plugin-hover/plugin.json` +- Modify: `plugin.json` +- Modify: `README.md` + +**Step 1: Write/adjust live plugin validation** + +Ensure the existing live server/import test can run with browser options: +- `HOVER_LIVE_TEST=1` +- `HOVER_USERNAME` +- `HOVER_PASSWORD` +- optional `HOVER_TOTP_SECRET` +- `HOVER_BROWSER_*` + +The test must load the real plugin/provider path and exercise Workflow IaC import/status behavior, not call the client directly only. + +**Step 2: Run live plugin validation** + +Run: + +```bash +HOVER_LIVE_TEST=1 \ +HOVER_BROWSER_HEADLESS=true \ +GOWORK=off go test ./internal -run 'Test.*Live' -count=1 -v +``` + +Expected: PASS with evidence that at least one Hover domain or delegation object is read/imported; no secret output. If the test account has zero domains, expected output may be an explicit "login succeeded; zero domains" assertion plus one non-mutating status/read path against a configured test domain if available. + +**Step 3: Validate real Workflow consumer path** + +From `/Users/jon/workspace/gocodealone-dns` or the current real consumer repo if renamed, run the smallest available Workflow/wfctl import or validation command that loads this local plugin build and uses the Hover provider. + +Expected: command reaches the real Hover provider and returns imported/read Hover state rather than `401 Invalid username or password` or `ErrBotChallenge`. + +**Step 4: Update manifest version/runtime docs** + +Set plugin manifests to `0.5.0` for the browser-auth behavioral release. README must describe: +- real Chrome/go-rod auth, not old CSRF form signin. +- Chrome install/download/profile config. +- sensitive profile directory. +- bot challenge typed failure and manual remediation. +- unchanged IaC usage. + +**Step 5: Run runtime/build validation** + +Run: + +```bash +GOWORK=off go build ./... +GOWORK=off go vet ./... +GOWORK=off go test ./... -count=1 +``` + +Expected: all commands exit 0. + +**Step 6: Commit** + +```bash +git add internal/iacserver_live_test.go cmd/workflow-plugin-hover/plugin.json plugin.json README.md +git commit -m "docs(hover): document browser auth runtime and release" +``` + +Rollback: revert commit; plugin remains at prior manifest version/docs. + +### Task 6: Final Security Review, Release Prep, and PR + +**Files:** +- Create: `docs/plans/2026-05-30-headless-browser-auth.security-review.md` +- Modify: `docs/plans/2026-05-30-headless-browser-auth-design.md` if execution disproves an assumption + +**Step 1: Run adversarial security review** + +Review final diff for: +- secret leakage in logs/errors/tests. +- browser profile path safety and gitignore coverage. +- no third-party credential/CAPTCHA service. +- Chrome download/path trust boundary. +- no public contract break. +- no mock-only claim for Imperva pass. + +Save findings in `docs/plans/2026-05-30-headless-browser-auth.security-review.md`. + +**Step 2: Run full verification** + +Run: + +```bash +GOWORK=off go test ./... -count=1 +GOWORK=off go vet ./... +GOWORK=off go build ./... +HOVER_LIVE_TEST=1 HOVER_BROWSER_HEADLESS=true GOWORK=off go test ./pkg/hoverclient ./internal -run 'TestLive|Test.*Live' -count=1 -v +``` + +Expected: all non-live commands PASS; live command PASS with real Hover evidence or the plan is blocked before PR. No secrets printed. + +**Step 3: Run scope verification** + +Run: + +```bash +/Users/jon/.codex/plugins/cache/autodev-marketplace/autodev/6.2.0/tests/plan-scope-check.sh --verify-lock docs/plans/2026-05-30-headless-browser-auth.md +``` + +Expected: PASS. + +**Step 4: Commit security review** + +```bash +git add docs/plans/2026-05-30-headless-browser-auth.security-review.md docs/plans/2026-05-30-headless-browser-auth-design.md +git commit -m "docs(hover): record browser auth security review" +``` + +**Step 5: Create PR** + +Push branch and open one PR: + +```bash +git push -u origin feat/headless-browser-auth-2026-05-30T2030 +gh pr create --fill --base main --head feat/headless-browser-auth-2026-05-30T2030 +``` + +Expected: one PR covering Tasks 1-6; PR body includes live Hover evidence and rollback note. + +Rollback: close PR or revert merged PR; pin consumers back to v0.4.2 knowing live Hover automation returns to known-broken HTTP behavior. From b85a12c3efc82e0a69c56823b460004a3bfe0b2e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:44:51 -0400 Subject: [PATCH 04/20] docs(hover): review browser auth implementation plan --- .../plans/2026-05-30-headless-browser-auth.md | 7 +++-- ...-30-headless-browser-auth.plan-review-1.md | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-05-30-headless-browser-auth.plan-review-1.md diff --git a/docs/plans/2026-05-30-headless-browser-auth.md b/docs/plans/2026-05-30-headless-browser-auth.md index 95dc4b7..a2048dd 100644 --- a/docs/plans/2026-05-30-headless-browser-auth.md +++ b/docs/plans/2026-05-30-headless-browser-auth.md @@ -121,6 +121,7 @@ Rollback: revert commit; no runtime behavior changed unless the opt-in live test **Files:** - Create: `pkg/hoverclient/backend.go` +- Create: `pkg/hoverclient/browser_backend.go` - Create: `pkg/hoverclient/options.go` - Modify: `pkg/hoverclient/client.go` - Modify: `pkg/hoverclient/client_test.go` @@ -168,7 +169,7 @@ type executionBackend interface { } ``` -Move current HTTP logic behind `httpBackend`. `Client` keeps `NewClient(creds, httpClient)` for compatibility; `httpClient != nil` selects `httpBackend`. Production `httpClient == nil` selects `browserBackend` in Task 3. +Move current HTTP logic behind `httpBackend`. Add a compile-valid `browserBackend` skeleton in `browser_backend.go` that stores `BrowserOptions` and returns `ErrBrowserBackendUnavailable` for live operations until Task 3 replaces the login implementation. `Client` keeps `NewClient(creds, httpClient)` for compatibility; `httpClient != nil` selects `httpBackend`; production `httpClient == nil` selects `browserBackend`. **Step 4: Implement provider config** @@ -189,7 +190,7 @@ Expected: PASS. **Step 6: Commit** ```bash -git add pkg/hoverclient/backend.go pkg/hoverclient/options.go pkg/hoverclient/client.go pkg/hoverclient/client_test.go internal/provider.go internal/provider_test.go +git add pkg/hoverclient/backend.go pkg/hoverclient/browser_backend.go pkg/hoverclient/options.go pkg/hoverclient/client.go pkg/hoverclient/client_test.go internal/provider.go internal/provider_test.go git commit -m "refactor(hoverclient): add browser backend configuration seam" ``` @@ -198,7 +199,7 @@ Rollback: revert commit; re-run `GOWORK=off go test ./pkg/hoverclient ./internal ### Task 3: Browser Backend Login, Stealth, and Typed Errors **Files:** -- Create: `pkg/hoverclient/browser_backend.go` +- Modify: `pkg/hoverclient/browser_backend.go` - Create: `pkg/hoverclient/browser_backend_test.go` - Modify: `pkg/hoverclient/client.go` - Modify: `pkg/hoverclient/backend.go` diff --git a/docs/plans/2026-05-30-headless-browser-auth.plan-review-1.md b/docs/plans/2026-05-30-headless-browser-auth.plan-review-1.md new file mode 100644 index 0000000..feef569 --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth.plan-review-1.md @@ -0,0 +1,28 @@ +# Headless-browser Hover auth — Plan Review 1 + +**Date:** 2026-05-30 +**Phase:** plan +**Design:** `docs/plans/2026-05-30-headless-browser-auth-design.md` +**Plan:** `docs/plans/2026-05-30-headless-browser-auth.md` +**Status:** PASS after plan correction + +## Findings + +| sev | class | loc | issue | resolution | +|---|---|---|---|---| +| Important | task ordering | Task 2 / Task 3 | Task 2 required `NewClient(creds, nil)` to select `browserBackend`, but Task 3 originally created `browser_backend.go`; Task 2 would not compile or would need hidden work. | Task 2 now creates a compile-valid `browserBackend` skeleton; Task 3 replaces skeleton behavior with real login. | +| Minor | live dependency | Task 1 | Live test can block if Hover test-account credentials are unavailable. | Intentional per design; plan states stop/block after Task 1 rather than proceeding with stub-only proof. | +| Minor | optimization | Task 4 | Go HTTP cookie reuse might be viable after Task 1. | Plan keeps browser default and requires design amendment before switching default away from full-browser. | + +## Clean Checks + +| area | result | +|---|---| +| Manifest | `plan-scope-check.sh --plan` PASS with absolute plan path. | +| Design coverage | live gate, backend seam, provider config, browser login, full-browser APIs, docs, runtime validation, security review all covered. | +| Scope control | one PR, six tasks, explicit non-goals, no Python/demo harness. | +| Rollback | each runtime-affecting task has revert + verification note. | + +## Verdict + +PASS. Execution may start after alignment-check and scope-lock. From 4b9d685f64482646c938b8560dfe8e6cdd1cfd3f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:45:21 -0400 Subject: [PATCH 05/20] docs(hover): align browser auth plan with design --- ...eadless-browser-auth.alignment-report-1.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/plans/2026-05-30-headless-browser-auth.alignment-report-1.md diff --git a/docs/plans/2026-05-30-headless-browser-auth.alignment-report-1.md b/docs/plans/2026-05-30-headless-browser-auth.alignment-report-1.md new file mode 100644 index 0000000..0e2a19a --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth.alignment-report-1.md @@ -0,0 +1,51 @@ +# Headless-browser Hover auth — Alignment Report 1 + +**Date:** 2026-05-30 +**Design:** `docs/plans/2026-05-30-headless-browser-auth-design.md` +**Plan:** `docs/plans/2026-05-30-headless-browser-auth.md` +**Status:** PASS + +## Coverage + +| Design requirement | Plan task(s) | Status | +|---|---|---| +| Replace cold HTTP signin with real Chrome/go-rod live path. | Task 1, Task 3 | Covered | +| Preserve `hoverclient` / provider / IaC driver public contracts. | Task 2, Task 4 | Covered | +| Keep tests from requiring Chrome by default via internal backend seam. | Task 2, Task 4 | Covered | +| Execute Hover requests in-browser by default, not separate Go HTTP. | Task 3, Task 4 | Covered | +| First validate live go-rod Imperva viability with verified test account and block if unavailable. | Task 1 | Covered | +| Probe whether browser→Go HTTP cookie reuse is viable without making it default. | Task 1, Task 4 | Covered | +| Expose Chrome runtime config: path/download/headless/profile dir plus env aliases. | Task 1, Task 2, Task 5 | Covered | +| Default sensitive browser profile under user state dir and gitignore local test profile paths. | Task 2, Task 3 | Covered | +| Detect bot challenge and Chrome acquisition failures with typed/actionable errors. | Task 3 | Covered | +| Preserve TOTP behavior and clear missing-TOTP error. | Task 1, Task 3 | Covered | +| Validate plugin + real Hover boundary through live provider/consumer path. | Task 5, Task 6 | Covered | +| Update README away from obsolete CSRF form-login docs. | Task 5 | Covered | +| Release as minor behavioral change `v0.5.0`. | Task 5 | Covered | +| Security/adversarial review before completion. | Task 6 | Covered | +| Rollback notes for runtime-affecting changes. | Task 2, Task 3, Task 4, Task 5, Task 6 | Covered | + +## Scope Check + +| Plan task | Design requirement | Status | +|---|---|---| +| Task 1: Live Browser Viability Gate | Live go-rod Imperva proof, test-account gate, HTTP-reuse probe, Chrome options bootstrap. | Justified | +| Task 2: Client Backend Seam and Provider Browser Config | Contract preservation, testability seam, provider runtime knobs, sensitive profile default. | Justified | +| Task 3: Browser Backend Login, Stealth, and Typed Errors | Real Chrome login, stealth, TOTP, bot challenge, Chrome missing/download-disabled errors. | Justified | +| Task 4: Full-Browser DNS and Delegation Operations | Browser-default live operations and current API semantics preservation. | Justified | +| Task 5: Workflow Plugin Runtime Validation With Real Consumer | Plugin+real Hover proof, README docs, manifest version. | Justified | +| Task 6: Final Security Review, Release Prep, and PR | Security review, full verification, scope verification, PR creation. | Justified | + +## Manifest Trace + +| Check | Result | +|---|---| +| `## Scope Manifest` present | PASS | +| `PR Count` matches PR table rows | PASS | +| `Tasks` count matches `### Task N` headings | PASS | +| Every task appears exactly once in PR table | PASS | +| Every PR row task exists in plan body | PASS | + +## Drift Items + +None. From 88e300a641da394452dfa9a2395418384144a97c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 20:45:38 -0400 Subject: [PATCH 06/20] chore: lock scope for hover browser auth --- docs/plans/2026-05-30-headless-browser-auth.md | 2 +- docs/plans/2026-05-30-headless-browser-auth.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-30-headless-browser-auth.md.scope-lock diff --git a/docs/plans/2026-05-30-headless-browser-auth.md b/docs/plans/2026-05-30-headless-browser-auth.md index a2048dd..47400b1 100644 --- a/docs/plans/2026-05-30-headless-browser-auth.md +++ b/docs/plans/2026-05-30-headless-browser-auth.md @@ -30,7 +30,7 @@ |------|-------|-------|--------| | 1 | Browser-backed Hover auth for Imperva-protected control panel | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/headless-browser-auth-2026-05-30T2030 | -**Status:** Draft +**Status:** Locked 2026-05-31T00:45:26Z ### Task 1: Live Browser Viability Gate diff --git a/docs/plans/2026-05-30-headless-browser-auth.md.scope-lock b/docs/plans/2026-05-30-headless-browser-auth.md.scope-lock new file mode 100644 index 0000000..07da8df --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth.md.scope-lock @@ -0,0 +1 @@ +1fa30e718e178d8aef4429926aef08eaf103a9d1d5c726079a2773c203637994 From 2b5dcea00d9f5b6c2c059613168df8b12b940cc5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 21:06:37 -0400 Subject: [PATCH 07/20] test(hoverclient): add live browser auth viability gate go-rod probe (ProbeLiveBrowserAuth) + opt-in live test. Launches Chrome, strips navigator.webdriver, waits for Imperva clearance cookies, submits signin (incl. 2FA path) via in-browser fetch, then probes whether a plain Go http.Client can reuse the clearance for /api/domains (login-only optimization signal). Live test skips unless HOVER_LIVE_TEST=1; never logs secrets. go-rod is the driver picked by the both-drivers Imperva spike. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 6 + go.sum | 16 ++ pkg/hoverclient/browser_live_test.go | 61 ++++++ pkg/hoverclient/browser_options.go | 77 +++++++ pkg/hoverclient/browser_probe.go | 314 +++++++++++++++++++++++++++ 5 files changed, 474 insertions(+) create mode 100644 pkg/hoverclient/browser_live_test.go create mode 100644 pkg/hoverclient/browser_options.go create mode 100644 pkg/hoverclient/browser_probe.go diff --git a/go.mod b/go.mod index a6d34bb..7b62580 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/GoCodeAlone/workflow v0.64.0 + github.com/go-rod/rod v0.116.2 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) @@ -120,6 +121,11 @@ require ( github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/got v0.40.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect diff --git a/go.sum b/go.sum index d8d29e2..77a250e 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -336,6 +338,20 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/pkg/hoverclient/browser_live_test.go b/pkg/hoverclient/browser_live_test.go new file mode 100644 index 0000000..61ae0b1 --- /dev/null +++ b/pkg/hoverclient/browser_live_test.go @@ -0,0 +1,61 @@ +package hoverclient + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestLiveBrowserLoginAndHTTPReuseProbe(t *testing.T) { + if os.Getenv("HOVER_LIVE_TEST") != "1" { + t.Skip("set HOVER_LIVE_TEST=1 to run live Hover browser auth probe") + } + creds := liveCredentialsFromEnv(t) + opts := liveBrowserOptionsFromEnv(t) + result, err := ProbeLiveBrowserAuth(context.Background(), creds, opts) + if err != nil { + t.Fatalf("live browser auth probe: %v", err) + } + if !result.LoginSucceeded { + t.Fatalf("login did not complete") + } + if len(result.ClearanceCookies) == 0 { + t.Fatalf("Imperva clearance cookies not observed") + } + t.Logf("go_http_reuse_viable=%t domains=%d clearance_cookies=%v", result.GoHTTPReuseViable, result.DomainCount, result.ClearanceCookieNames()) +} + +func liveCredentialsFromEnv(t *testing.T) Credentials { + t.Helper() + var missing []string + username := strings.TrimSpace(os.Getenv("HOVER_USERNAME")) + password := strings.TrimRight(os.Getenv("HOVER_PASSWORD"), "\r\n") + if username == "" { + missing = append(missing, "HOVER_USERNAME") + } + if password == "" { + missing = append(missing, "HOVER_PASSWORD") + } + if len(missing) > 0 { + t.Fatalf("missing live Hover env: %s", strings.Join(missing, ", ")) + } + var totp TOTPSecret + if raw := strings.TrimSpace(os.Getenv("HOVER_TOTP_SECRET")); raw != "" { + parsed, err := ParseBase32(raw) + if err != nil { + t.Fatalf("invalid HOVER_TOTP_SECRET: %v", err) + } + totp = parsed + } + return Credentials{Username: username, Password: password, TOTPSecret: totp} +} + +func liveBrowserOptionsFromEnv(t *testing.T) BrowserOptions { + t.Helper() + opts, err := BrowserOptionsFromEnv() + if err != nil { + t.Fatalf("browser options from env: %v", err) + } + return opts +} diff --git a/pkg/hoverclient/browser_options.go b/pkg/hoverclient/browser_options.go new file mode 100644 index 0000000..7dfcf2b --- /dev/null +++ b/pkg/hoverclient/browser_options.go @@ -0,0 +1,77 @@ +package hoverclient + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const defaultBrowserTimeout = 90 * time.Second + +// BrowserOptions controls the local Chrome instance used for live Hover access. +type BrowserOptions struct { + Path string + Download bool + Headless bool + ProfileDir string + Timeout time.Duration +} + +// BrowserOptionsFromEnv returns BrowserOptions using HOVER_BROWSER_* env aliases. +func BrowserOptionsFromEnv() (BrowserOptions, error) { + opts := DefaultBrowserOptions() + if path := firstNonEmpty(os.Getenv("HOVER_BROWSER_PATH"), os.Getenv("ROD_BROWSER_PATH")); path != "" { + opts.Path = path + } + if raw := strings.TrimSpace(os.Getenv("HOVER_BROWSER_DOWNLOAD")); raw != "" { + v, err := strconv.ParseBool(raw) + if err != nil { + return BrowserOptions{}, fmt.Errorf("HOVER_BROWSER_DOWNLOAD must be boolean: %w", err) + } + opts.Download = v + } + if raw := strings.TrimSpace(os.Getenv("HOVER_BROWSER_HEADLESS")); raw != "" { + v, err := strconv.ParseBool(raw) + if err != nil { + return BrowserOptions{}, fmt.Errorf("HOVER_BROWSER_HEADLESS must be boolean: %w", err) + } + opts.Headless = v + } + if dir := strings.TrimSpace(os.Getenv("HOVER_BROWSER_PROFILE_DIR")); dir != "" { + opts.ProfileDir = dir + } + return opts, nil +} + +// DefaultBrowserOptions returns conservative runtime defaults for Chrome. +func DefaultBrowserOptions() BrowserOptions { + return BrowserOptions{ + Download: true, + Headless: true, + ProfileDir: defaultBrowserProfileDir(), + Timeout: defaultBrowserTimeout, + } +} + +func defaultBrowserProfileDir() string { + if state := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); state != "" { + return filepath.Join(state, "wfctl", "plugins", "hover", "browser-profile") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(os.TempDir(), "wfctl", "plugins", "hover", "browser-profile") + } + return filepath.Join(home, ".local", "state", "wfctl", "plugins", "hover", "browser-profile") +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/pkg/hoverclient/browser_probe.go b/pkg/hoverclient/browser_probe.go new file mode 100644 index 0000000..014864b --- /dev/null +++ b/pkg/hoverclient/browser_probe.go @@ -0,0 +1,314 @@ +package hoverclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +// LiveAuthProbeResult captures the live Hover browser-auth viability signal. +type LiveAuthProbeResult struct { + LoginSucceeded bool + ClearanceCookies []string + GoHTTPReuseViable bool + DomainCount int +} + +func (r LiveAuthProbeResult) ClearanceCookieNames() []string { + out := append([]string(nil), r.ClearanceCookies...) + return out +} + +// ProbeLiveBrowserAuth exercises real Chrome against Hover. It is intended for +// opt-in live tests only; it never logs credentials or TOTP material. +func ProbeLiveBrowserAuth(ctx context.Context, creds Credentials, opts BrowserOptions) (LiveAuthProbeResult, error) { + if opts.Timeout <= 0 { + opts.Timeout = defaultBrowserTimeout + } + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + browser, cleanup, err := launchProbeBrowser(ctx, opts) + if err != nil { + return LiveAuthProbeResult{}, err + } + defer cleanup() + + page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + return LiveAuthProbeResult{}, fmt.Errorf("hover browser probe: new page: %w", err) + } + defer func() { _ = page.Close() }() + page = page.Context(ctx) + _, _ = page.EvalOnNewDocument(`() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }`) + _ = page.SetUserAgent(&proto.NetworkSetUserAgentOverride{ + UserAgent: defaultUserAgent, + AcceptLanguage: "en-US,en;q=0.9", + Platform: "macOS", + }) + if err := page.Navigate(hoverHost + "/signin"); err != nil { + return LiveAuthProbeResult{}, fmt.Errorf("hover browser probe: navigate signin: %w", err) + } + if err := page.WaitLoad(); err != nil { + return LiveAuthProbeResult{}, fmt.Errorf("hover browser probe: wait signin load: %w", err) + } + + clearance, err := waitForClearanceCookies(ctx, browser) + if err != nil { + return LiveAuthProbeResult{}, err + } + if err := submitBrowserSignin(ctx, page, creds); err != nil { + return LiveAuthProbeResult{}, err + } + domains, httpReuse := probeGoHTTPReuse(ctx, browser) + return LiveAuthProbeResult{ + LoginSucceeded: true, + ClearanceCookies: clearance, + GoHTTPReuseViable: httpReuse, + DomainCount: domains, + }, nil +} + +func launchProbeBrowser(ctx context.Context, opts BrowserOptions) (*rod.Browser, func(), error) { + if err := os.MkdirAll(opts.ProfileDir, 0o700); err != nil { + return nil, nil, fmt.Errorf("hover browser probe: create profile dir: %w", err) + } + launcher := launcher.New(). + Context(ctx). + HeadlessNew(opts.Headless). + UserDataDir(opts.ProfileDir). + KeepUserDataDir() + + if opts.Path != "" { + launcher = launcher.Bin(opts.Path) + } else if path, ok := findChromeBinary(); ok { + launcher = launcher.Bin(path) + } else if !opts.Download { + return nil, nil, fmt.Errorf("hover browser probe: no Chrome found; install Chrome or set HOVER_BROWSER_DOWNLOAD=true") + } + controlURL, err := launcher.Launch() + if err != nil { + return nil, nil, fmt.Errorf("hover browser probe: launch Chrome: %w", err) + } + browser := rod.New().Context(ctx).ControlURL(controlURL) + if err := browser.Connect(); err != nil { + launcher.Cleanup() + return nil, nil, fmt.Errorf("hover browser probe: connect Chrome: %w", err) + } + cleanup := func() { + _ = browser.Close() + launcher.Cleanup() + } + return browser, cleanup, nil +} + +func findChromeBinary() (string, bool) { + candidates := []string{ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + for _, candidate := range candidates { + if filepath.IsAbs(candidate) { + if st, err := os.Stat(candidate); err == nil && !st.IsDir() { + return candidate, true + } + continue + } + if path, err := exec.LookPath(candidate); err == nil { + return path, true + } + } + return "", false +} + +func waitForClearanceCookies(ctx context.Context, browser *rod.Browser) ([]string, error) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + names, err := clearanceCookieNames(browser) + if err != nil { + return nil, fmt.Errorf("hover browser probe: read cookies: %w", err) + } + if len(names) > 0 { + return names, nil + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("hover browser probe: wait for Imperva clearance cookies: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func clearanceCookieNames(browser *rod.Browser) ([]string, error) { + cookies, err := browser.GetCookies() + if err != nil { + return nil, err + } + seen := map[string]bool{} + var names []string + for _, cookie := range cookies { + if isClearanceCookie(cookie.Name) && !seen[cookie.Name] { + seen[cookie.Name] = true + names = append(names, cookie.Name) + } + } + return names, nil +} + +func isClearanceCookie(name string) bool { + switch name { + case "__uzma", "__uzmb", "__uzmc", "__uzmd", "__uzme", "uzmx": + return true + default: + return strings.HasPrefix(name, "__ss") + } +} + +type browserSigninResult struct { + OK bool `json:"ok"` + HTTPCode int `json:"httpCode"` + Succeeded bool `json:"succeeded"` + Status string `json:"status"` + Error string `json:"error"` + Raw string `json:"raw"` +} + +func submitBrowserSignin(ctx context.Context, page *rod.Page, creds Credentials) error { + result, err := browserSigninFetch(ctx, page, "/signin/auth.json", map[string]any{ + "username": creds.Username, + "password": creds.Password, + "remember": false, + "token": nil, + }) + if err != nil { + return err + } + if result.Status == "need_2fa" { + if creds.TOTPSecret.key == nil { + return fmt.Errorf("hover: account has MFA enabled but no totp_secret was provided") + } + result, err = browserSigninFetch(ctx, page, "/signin/auth2.json", map[string]any{ + "code": creds.TOTPSecret.Code(), + "remember": false, + }) + if err != nil { + return err + } + } + if !result.OK { + if result.Error != "" { + return fmt.Errorf("hover browser signin: HTTP %d: %s", result.HTTPCode, result.Error) + } + return fmt.Errorf("hover browser signin: HTTP %d: %s", result.HTTPCode, strings.TrimSpace(result.Raw)) + } + if result.Error != "" { + return fmt.Errorf("hover browser signin: %s", result.Error) + } + return nil +} + +func browserSigninFetch(ctx context.Context, page *rod.Page, endpoint string, payload map[string]any) (browserSigninResult, error) { + obj, err := page.Context(ctx).Evaluate(&rod.EvalOptions{ + ByValue: true, + AwaitPromise: true, + JS: `async (endpoint, payload) => { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(payload) + }); + const raw = await response.text(); + let parsed = {}; + try { parsed = raw ? JSON.parse(raw) : {}; } catch (e) {} + return { + ok: response.ok, + httpCode: response.status, + succeeded: !!parsed.succeeded, + status: parsed.status || '', + error: parsed.error || '', + raw + }; + }`, + JSArgs: []any{endpoint, payload}, + }) + if err != nil { + return browserSigninResult{}, fmt.Errorf("hover browser signin: fetch %s: %w", endpoint, err) + } + var result browserSigninResult + if err := obj.Value.Unmarshal(&result); err != nil { + return browserSigninResult{}, fmt.Errorf("hover browser signin: decode %s result: %w", endpoint, err) + } + return result, nil +} + +func probeGoHTTPReuse(ctx context.Context, browser *rod.Browser) (int, bool) { + cookies, err := browser.GetCookies() + if err != nil { + return 0, false + } + jar, err := cookiejar.New(nil) + if err != nil { + return 0, false + } + hoverURL, _ := url.Parse(hoverHost) + var httpCookies []*http.Cookie + for _, cookie := range cookies { + httpCookies = append(httpCookies, &http.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + Path: cookie.Path, + Domain: cookie.Domain, + Secure: cookie.Secure, + HttpOnly: cookie.HTTPOnly, + }) + } + jar.SetCookies(hoverURL, httpCookies) + client := &http.Client{Jar: jar, Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, hoverHost+"/api/domains", nil) + if err != nil { + return 0, false + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", defaultUserAgent) + resp, err := client.Do(req) + if err != nil { + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, false + } + var body struct { + Succeeded bool `json:"succeeded"` + Domains []Domain `json:"domains"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return 0, false + } + return len(body.Domains), body.Succeeded +} From 1b8b27661b022de25f6bdd2691602afdb3e18db8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 21:06:37 -0400 Subject: [PATCH 08/20] docs(hover): backport go-rod driver spike evidence into design Records that both playwright-go and go-rod cleared Imperva headless in the scratch spike; go-rod picked for pure-Go runtime. De-risks Assumption #1. Full authenticated login still gated on test-account verification. No manifest change; no scope unlock required. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-05-30-headless-browser-auth-design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index 0bc4715..15a0314 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -111,3 +111,12 @@ Per user: re-check for maintained Imperva-bypass solutions periodically (next: ~ ## ADR - **ADR 0001** — Drive Hover auth with a real browser (go-rod) to defeat Imperva ABP; reject HTTP-scrape (cannot pass), managed solvers (cred-leak), and Python tooling (wrong language). + +## Backport — driver spike (2026-05-30) + +De-risks Assumption #1 (go-rod can pass Imperva). Per the user's "spike both, pick the winner": a throwaway scratch module (`/tmp/hover-imperva-spike`) drove **both** playwright-go (manages its own Chromium) and **go-rod + go-rod/stealth** against live `hover.com/signin`. + +- **Result:** BOTH minted Imperva clearance cookies (`__uzm*`) headless — i.e. both clear Imperva's JS-sensor handshake that the cold Go `http.Client` cannot. The earlier 401 is confirmed an Imperva block, not creds/headers/token. +- **Pick: go-rod.** Equal Imperva clearance, but pure-Go at runtime (no Python/Node bolted onto a Go gRPC plugin) — consistent with the design's "Primary Go" guidance. playwright-go would drag a Node-driven Playwright server + browser download into the plugin runtime. +- **Scope of evidence:** confirms clearance-cookie minting + signin-form reachability. It does **not** yet prove a full *authenticated* login (the test account `gcadnstest` is pending email verification). The full authenticated login + `go_http_reuse_viable` signal remain gated by the plan's Task 1 live run once the account is verified. +- **Manifest impact:** none. go-rod was already the locked driver; this only records empirical confirmation. No scope unlock required. From 9d757884aa9d613dc92e4aa1b4d85d93cfff0a43 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 21:08:04 -0400 Subject: [PATCH 09/20] docs(hover): record 2026-05-30 Imperva-bypass re-check (go-rod unchanged) No maintained Go-native Imperva-bypass lib; SOTA stealth tools all Python/Node (wrong language per ADR 0001). New 2026 signals (JA4 + UA-CH consistency) reinforce full-browser default. CDP-protocol fingerprinting flagged as most likely future-break vector for any CDP driver incl go-rod. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-05-30-headless-browser-auth-design.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index 15a0314..a49fd00 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -106,7 +106,9 @@ Runtime-affecting (auth path + new browser dependency + CI). Rollback path: ## Re-evaluation cadence -Per user: re-check for maintained Imperva-bypass solutions periodically (next: ~2026-05-30 search done — no maintained Go lib; revisit if go-rod stealth proves insufficient or breaks). Record findings in the retro. +Per user: re-check for maintained Imperva-bypass solutions periodically. Record findings in the retro. + +**2026-05-30 re-check (done):** No maintained **Go-native** Imperva-bypass library exists. The only actively-maintained Go headless drivers are chromedp, playwright-go, and go-rod — all general CDP drivers, none stealth-specialized. The 2026 SOTA stealth tools (Camoufox, nodriver, patchright, CloakBrowser) are **all Python/Node**, confirming ADR 0001's rejection of cross-language tooling for a Go gRPC plugin. New 2026 Imperva signals — JA4 TLS fingerprinting + *mandatory* UA-Client-Hints consistency with the declared UA + sequential challenge escalation — **reinforce the full-browser default** (handing cookies to Go's `http.Client` exposes a non-Chrome JA4/TLS shape; in-browser keeps it consistent). **Decision unchanged: go-rod.** Watch item: reports say "automation-protocol-fingerprinting" is the cliff where CDP-driven Playwright forks fail regardless of patch quality — go-rod is also CDP-driven, so if Imperva escalates to CDP-protocol detection, go-rod could break where flag-stripped nodriver passes; the spike shows go-rod clears Hover *today*, but record this as the most likely future-break vector. Next re-check on break or ~quarterly. ## ADR From 66ed5e1dd59ab9242520b864665f0ad24de6ad52 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 22:59:58 -0400 Subject: [PATCH 10/20] fix(hoverclient): keep profile dir on local launcher (Kill, not Cleanup) Live gate caught a panic: go-rod KeepUserDataDir() only works on a managed launcher; on a local launcher it panics (mustManaged). And Cleanup() deletes UserDataDir. Drop KeepUserDataDir(); use Kill() in cleanup so the persistent profile (and its Imperva clearance/cookies) survives across calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/hoverclient/browser_probe.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/hoverclient/browser_probe.go b/pkg/hoverclient/browser_probe.go index 014864b..23640d6 100644 --- a/pkg/hoverclient/browser_probe.go +++ b/pkg/hoverclient/browser_probe.go @@ -88,11 +88,14 @@ func launchProbeBrowser(ctx context.Context, opts BrowserOptions) (*rod.Browser, if err := os.MkdirAll(opts.ProfileDir, 0o700); err != nil { return nil, nil, fmt.Errorf("hover browser probe: create profile dir: %w", err) } + // Local launcher: UserDataDir persists by default; Cleanup() would delete it + // (and KeepUserDataDir() panics on a non-managed launcher), so keep the + // profile dir and use Kill() in cleanup to tear down only the process. This + // preserves the Imperva clearance/cookie state across calls. launcher := launcher.New(). Context(ctx). HeadlessNew(opts.Headless). - UserDataDir(opts.ProfileDir). - KeepUserDataDir() + UserDataDir(opts.ProfileDir) if opts.Path != "" { launcher = launcher.Bin(opts.Path) @@ -107,12 +110,12 @@ func launchProbeBrowser(ctx context.Context, opts BrowserOptions) (*rod.Browser, } browser := rod.New().Context(ctx).ControlURL(controlURL) if err := browser.Connect(); err != nil { - launcher.Cleanup() + launcher.Kill() return nil, nil, fmt.Errorf("hover browser probe: connect Chrome: %w", err) } cleanup := func() { _ = browser.Close() - launcher.Cleanup() + launcher.Kill() } return browser, cleanup, nil } From 2656e49f7e4f83a5a06e493fc4cd0b47c5b7b791 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 23:00:24 -0400 Subject: [PATCH 11/20] =?UTF-8?q?docs(hover):=20backport=20live-gate=20res?= =?UTF-8?q?ult=20=E2=80=94=20Imperva=20cleared,=202FA=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-rod reached auth.json (need_2fa), not the Imperva 401 → Assumption #1 validated. Records Hover's email-default 2FA, new-device challenge, and the CI auth model (TOTP secret and/or persistent trusted profile). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-05-30-headless-browser-auth-design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index a49fd00..3ee99eb 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -122,3 +122,12 @@ De-risks Assumption #1 (go-rod can pass Imperva). Per the user's "spike both, pi - **Pick: go-rod.** Equal Imperva clearance, but pure-Go at runtime (no Python/Node bolted onto a Go gRPC plugin) — consistent with the design's "Primary Go" guidance. playwright-go would drag a Node-driven Playwright server + browser download into the plugin runtime. - **Scope of evidence:** confirms clearance-cookie minting + signin-form reachability. It does **not** yet prove a full *authenticated* login (the test account `gcadnstest` is pending email verification). The full authenticated login + `go_http_reuse_viable` signal remain gated by the plan's Task 1 live run once the account is verified. - **Manifest impact:** none. go-rod was already the locked driver; this only records empirical confirmation. No scope unlock required. + +## Backport — live gate run (2026-05-30): Imperva CLEARED; 2FA reality + +Ran the Task-1 live gate (`ProbeLiveBrowserAuth`, go-rod headless) against verified test account `gcadnstest2`. + +- **Assumption #1 VALIDATED end-to-end.** go-rod cleared Imperva for a full authenticated login: the in-browser `fetch('/signin/auth.json')` returned a real `{status:"need_2fa"}` JSON — **not** the `401 Invalid username or password` Imperva block that killed the cold HTTP client. Clearance cookies were minted (the probe blocks on `__uzm*` presence before submitting). The design's central, most-fragile hypothesis is proven; **do not stop / do not backport-away the design** — proceed. +- **Bug caught by the live gate** (the exact value of "live gate first"): `launcher.KeepUserDataDir()` panics on a non-managed launcher. Fixed → keep the persistent profile via `Kill()` (not `Cleanup()`, which deletes `UserDataDir`). +- **2FA reality (new finding).** Hover defaults new accounts to **email-based** Two-Step Sign In ("Email On" in Account Settings → Security), challenged on **new-device** logins. The probe handles only **TOTP** (`auth2.json` + `HOVER_TOTP_SECRET`), which is what the **production** account uses. Email-OTP is **not** CI-automatable (no programmatic catch-all access in CI). +- **CI auth model (consequence).** Automated CI login needs ONE of: (a) the account on **authenticator/TOTP** 2FA + `HOVER_TOTP_SECRET` (production model — the probe's coded path); and/or (b) a **persistent trusted-device profile** on the self-hosted runner (`browser_profile_dir`, already in the design) so 2FA isn't re-challenged after the first trusted login. Email-default 2FA accounts cannot complete login headless in CI. Plan implication: Task 3 must treat email-OTP `need_2fa` (no TOTP) as a typed, actionable error ("account uses email 2FA — switch to authenticator or pre-trust the profile"), distinct from `ErrBotChallenge`. From 14fb2f85e54587f8ad019923a073ca2788fc1442 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 17:34:12 -0400 Subject: [PATCH 12/20] chore(hover): Go 1.26.3 + x/net v0.55.0 + gopls modernize Bumps go directive 1.26.0->1.26.3 (fixes stdlib html/template/net/net/http vulns GO-2026-4982/4980/4971/4918) and x/net 0.54.0->0.55.0 (GO-2026-5026). gopls modernize: maps.Copy for manual copy loops. govulncheck 7->2 affecting (remaining 2 are docker/docker via workflow SDK, no upstream fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 6 +++--- go.sum | 8 ++++---- internal/iacserver.go | 13 ++++--------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 7b62580..cf7c84c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/workflow-plugin-hover -go 1.26.0 +go 1.26.3 require ( github.com/GoCodeAlone/workflow v0.64.0 @@ -141,10 +141,10 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect diff --git a/go.sum b/go.sum index 77a250e..0c29b3f 100644 --- a/go.sum +++ b/go.sum @@ -412,8 +412,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -437,8 +437,8 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/iacserver.go b/internal/iacserver.go index e5e3b7a..085a246 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -26,6 +26,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "math" "time" @@ -194,9 +195,7 @@ func (s *hoverIaCServer) EnumerateAll(ctx context.Context, req *pb.EnumerateAllR return nil, fmt.Errorf("hover iacserver: encode EnumerateAll outputs: %w", err) } sensitive := make(map[string]bool, len(o.Sensitive)) - for k, v := range o.Sensitive { - sensitive[k] = v - } + maps.Copy(sensitive, o.Sensitive) pbOuts = append(pbOuts, &pb.ResourceOutput{ Name: o.Name, Type: o.Type, @@ -695,9 +694,7 @@ func copyBoolMap(in map[string]bool) map[string]bool { return nil } out := make(map[string]bool, len(in)) - for k, v := range in { - out[k] = v - } + maps.Copy(out, in) return out } @@ -757,8 +754,6 @@ func copyStringMap(m map[string]string) map[string]string { return nil } out := make(map[string]string, len(m)) - for k, v := range m { - out[k] = v - } + maps.Copy(out, m) return out } From 30e10f5387fc56f0288a8fc2b43c066624b3e62e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 17:46:55 -0400 Subject: [PATCH 13/20] docs(hover): ADR 0002 fork go-rod -> GoCodeAlone/rod + design backport Records the maintained-fork decision (amends ADR 0001's driver source) and backports the CI auth model + scope amendment (dep source change + prod live-login proof via gh workflow on the self-hosted runner). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-go-rod-for-maintenance-and-dep-control.md | 28 +++++++++++++++++++ ...2026-05-30-headless-browser-auth-design.md | 6 ++++ 2 files changed, 34 insertions(+) create mode 100644 decisions/0002-fork-go-rod-for-maintenance-and-dep-control.md diff --git a/decisions/0002-fork-go-rod-for-maintenance-and-dep-control.md b/decisions/0002-fork-go-rod-for-maintenance-and-dep-control.md new file mode 100644 index 0000000..f6c66e6 --- /dev/null +++ b/decisions/0002-fork-go-rod-for-maintenance-and-dep-control.md @@ -0,0 +1,28 @@ +# 0002. Fork go-rod to GoCodeAlone/rod for maintenance + dep control + +**Status:** Accepted +**Date:** 2026-05-31 +**Decision-makers:** codingsloth@pm.me (directed), Claude (Opus 4.8) +**Related:** decisions/0001-real-browser-auth-for-imperva.md (amends the driver source), docs/plans/2026-05-30-headless-browser-auth-design.md + +## Context + +ADR 0001 chose go-rod (CDP) to defeat Imperva. But `github.com/go-rod/rod` is **stale**: no release since 2024, 179 open issues, 27 open PRs, 425 forks. The Imperva arms race means we will eventually need our own patches and dep bumps, and upstream has no active fix channel. `govulncheck` on the pinned tree is clean (no *called* vulns), but GitHub Dependabot flags 2 moderate `golang.org/x/sys` advisories, and we cannot get upstream to act. We need durable control over the browser driver our DNS automation depends on. + +## Decision + +Fork to **`github.com/GoCodeAlone/rod`** and maintain it ourselves. We **rename the module path** (not just a filesystem replace) so consumers can `require`/`replace` the fork with a matching module path in CI — a versioned `replace` to a differently-pathed module fails Go's module-path check. Bump the go directive 1.21 → 1.26.3. Keep divergence from upstream **minimal** to preserve mergeability: do NOT bleeding-edge-bump deps that break the API for no vuln benefit (fetchup 0.5.x breaks the launcher), and do NOT run whole-repo `modernize` (it churns 2600+ lines of generated proto). Address Dependabot (x/sys). `workflow-plugin-hover` imports `github.com/GoCodeAlone/rod` directly. + +Alternatives rejected: +- **Stay on go-rod/rod** — stale, no fix channel, no control; the original problem. +- **Filesystem/replace without rename** — `replace ... => GoCodeAlone/rod vX` fails the module-path match; filesystem replace isn't CI-portable. +- **Switch driver (chromedp / playwright-go)** — chromedp is not stealth-focused; playwright-go drags a Node-driven Playwright runtime onto a Go gRPC plugin (rejected in ADR 0001). The driver spike already picked go-rod. + +## Consequences + +- We own go-rod maintenance: periodically merge upstream, apply our patches/dep bumps, run our own vuln gate (CodeQL on the fork + `govulncheck` + Dependabot). +- Divergence kept small → upstream merges stay feasible; the rename is the one large mechanical delta (104 files). +- Vuln posture: `govulncheck` clean + Dependabot x/sys addressed. +- The 3 nested example/tooling modules stay on the upstream path (not maintained surface; not in hover's dep graph). +- Undo cost: low — revert hover's import/require back to `github.com/go-rod/rod`. +- Negative: a maintained fork is ongoing work; if upstream revives, re-evaluate whether to drop the fork. diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index 3ee99eb..f851826 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -131,3 +131,9 @@ Ran the Task-1 live gate (`ProbeLiveBrowserAuth`, go-rod headless) against verif - **Bug caught by the live gate** (the exact value of "live gate first"): `launcher.KeepUserDataDir()` panics on a non-managed launcher. Fixed → keep the persistent profile via `Kill()` (not `Cleanup()`, which deletes `UserDataDir`). - **2FA reality (new finding).** Hover defaults new accounts to **email-based** Two-Step Sign In ("Email On" in Account Settings → Security), challenged on **new-device** logins. The probe handles only **TOTP** (`auth2.json` + `HOVER_TOTP_SECRET`), which is what the **production** account uses. Email-OTP is **not** CI-automatable (no programmatic catch-all access in CI). - **CI auth model (consequence).** Automated CI login needs ONE of: (a) the account on **authenticator/TOTP** 2FA + `HOVER_TOTP_SECRET` (production model — the probe's coded path); and/or (b) a **persistent trusted-device profile** on the self-hosted runner (`browser_profile_dir`, already in the design) so 2FA isn't re-challenged after the first trusted login. Email-default 2FA accounts cannot complete login headless in CI. Plan implication: Task 3 must treat email-OTP `need_2fa` (no TOTP) as a typed, actionable error ("account uses email 2FA — switch to authenticator or pre-trust the profile"), distinct from `ErrBotChallenge`. + +## Backport — driver forked to GoCodeAlone/rod (2026-05-31) + +Per user direction (go-rod stale since 2024; want dep/vuln control), the driver is now the **maintained fork `github.com/GoCodeAlone/rod`** instead of upstream `github.com/go-rod/rod`. See **ADR 0002**. Fork is renamed-module, Go 1.26.3, `govulncheck`-clean + Dependabot-clean (x/sys addressed), CodeQL-green; divergence kept minimal for upstream-mergeability. `hoverclient` will import `github.com/GoCodeAlone/rod` directly. + +**Scope amendment (user-approved):** adds (1) a one-line dependency-source change in `hoverclient` (import + `go.mod` require → `GoCodeAlone/rod@`); (2) the production live-login proof runs via a **GitHub Actions workflow on the self-hosted runner** (where `HOVER_*` secrets live) — NOT locally; the user will not provide prod creds locally. PR count for hover unchanged (still 1). No task added; the dep source + the CI-run venue are substitutions within the locked manifest's existing Task 2 (dep wiring) and Task 5 (real-consumer runtime validation). Recorded here + in ADR 0002; no further unlock needed. From 5887f54b18469a4236ec5d95b72907f6ad3b2b8f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 17:50:38 -0400 Subject: [PATCH 14/20] feat(hoverclient): use GoCodeAlone/rod fork for the browser driver Repoints the go-rod CDP driver to the maintained fork github.com/GoCodeAlone/rod v0.116.3 (ADR 0002). Build/vet/unit green; govulncheck adds no new vulns (only the pre-existing docker/docker SDK transitive remains, no upstream fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- pkg/hoverclient/browser_probe.go | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index cf7c84c..6b41eaf 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/GoCodeAlone/workflow-plugin-hover go 1.26.3 require ( + github.com/GoCodeAlone/rod v0.116.3 github.com/GoCodeAlone/workflow v0.64.0 - github.com/go-rod/rod v0.116.2 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) diff --git a/go.sum b/go.sum index 0c29b3f..65ff1e7 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/GoCodeAlone/modular/modules/auth v1.15.0 h1:pBSkPSf4k4GLSbUQFLuPa+nFb github.com/GoCodeAlone/modular/modules/auth v1.15.0/go.mod h1:vmIm/LQrcURS2p02YwaELb+CZoHPtT0XB0v1i+sj9i4= github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 h1:buYs0TGNbAZgtTq1Qb+dfmTv3+ZOBIN0HbvVBLyNqxE= github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0/go.mod h1:329flAKmwrPq2JEwu9iltWv6A83H/Di82Xze+kvdKDw= +github.com/GoCodeAlone/rod v0.116.3 h1:SLFj5CvlILppZGR0TmJrx4aB1Ci94JbSLMweU4GqbjE= +github.com/GoCodeAlone/rod v0.116.3/go.mod h1:EuinKGPqynSdDvB3p93EuULVS/1zV/DX975FOjqKOcw= github.com/GoCodeAlone/workflow v0.64.0 h1:2CpbYPwIqdGDb3xi3YJpwcteIum4ehBSrnRql/1YvB4= github.com/GoCodeAlone/workflow v0.64.0/go.mod h1:659GGDrw3QJ7b625y9rf8QhKIpt1VCoEG0MxKu5tGQs= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= @@ -124,8 +126,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= -github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= diff --git a/pkg/hoverclient/browser_probe.go b/pkg/hoverclient/browser_probe.go index 23640d6..5ddfc3a 100644 --- a/pkg/hoverclient/browser_probe.go +++ b/pkg/hoverclient/browser_probe.go @@ -14,9 +14,9 @@ import ( "strings" "time" - "github.com/go-rod/rod" - "github.com/go-rod/rod/lib/launcher" - "github.com/go-rod/rod/lib/proto" + "github.com/GoCodeAlone/rod" + "github.com/GoCodeAlone/rod/lib/launcher" + "github.com/GoCodeAlone/rod/lib/proto" ) // LiveAuthProbeResult captures the live Hover browser-auth viability signal. From 87ae0aa58c7f079f458bffc10a7d7240d44d7bac Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 17:56:23 -0400 Subject: [PATCH 15/20] docs(hover): backport production live proof (Imperva cleared, TOTP, 30 domains) CI probe on self-hosted runner vs production: go-rod fork clears Imperva, TOTP completes new-device 2FA, 30 domains read, go_http_reuse_viable=true. Adopts login-only transport (browser login -> Go HTTP for API) per the design's conditional, with full-browser fallback for the TLS-fingerprint risk and write paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-05-30-headless-browser-auth-design.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/plans/2026-05-30-headless-browser-auth-design.md b/docs/plans/2026-05-30-headless-browser-auth-design.md index f851826..06df50c 100644 --- a/docs/plans/2026-05-30-headless-browser-auth-design.md +++ b/docs/plans/2026-05-30-headless-browser-auth-design.md @@ -137,3 +137,22 @@ Ran the Task-1 live gate (`ProbeLiveBrowserAuth`, go-rod headless) against verif Per user direction (go-rod stale since 2024; want dep/vuln control), the driver is now the **maintained fork `github.com/GoCodeAlone/rod`** instead of upstream `github.com/go-rod/rod`. See **ADR 0002**. Fork is renamed-module, Go 1.26.3, `govulncheck`-clean + Dependabot-clean (x/sys addressed), CodeQL-green; divergence kept minimal for upstream-mergeability. `hoverclient` will import `github.com/GoCodeAlone/rod` directly. **Scope amendment (user-approved):** adds (1) a one-line dependency-source change in `hoverclient` (import + `go.mod` require → `GoCodeAlone/rod@`); (2) the production live-login proof runs via a **GitHub Actions workflow on the self-hosted runner** (where `HOVER_*` secrets live) — NOT locally; the user will not provide prod creds locally. PR count for hover unchanged (still 1). No task added; the dep source + the CI-run venue are substitutions within the locked manifest's existing Task 2 (dep wiring) and Task 5 (real-consumer runtime validation). Recorded here + in ADR 0002; no further unlock needed. + +## Backport — PRODUCTION live proof (2026-06-01) + +Ran the probe in CI (gocodealone-dns `hover-live-auth-probe.yml`, self-hosted runner, headless) against the **production** Hover account via org `HOVER_*` secrets. Result: + +``` +go_http_reuse_viable=true domains=30 clearance_cookies=[__uzma __uzmb __uzme __uzmc __uzmd __ssds __ssuzjsr0] +--- PASS (5.34s) +``` + +Validates, end-to-end against production: +1. **`GoCodeAlone/rod` clears Imperva** headless (clearance cookies minted). +2. **TOTP completes Hover's new-device 2FA** — a TOTP-configured account is challenged with TOTP (not email-OTP), so `HOVER_TOTP_SECRET` is sufficient for headless CI login. (Email-default accounts like the test account remain non-CI-viable — keep the typed email-2FA error.) +3. **30 production domains** enumerated; no secret leakage. +4. **Assumption #3 CONFIRMED: Imperva clears the SESSION, not per-request.** `go_http_reuse_viable=true` — a plain Go `http.Client` reusing the browser's clearance cookies successfully called `/api/domains`. + +**Decision — adopt login-only (per the design's own conditional).** Browser only for **login** (run the Imperva JS sensor + 2FA → mint clearance), then hand the clearance cookies to the existing Go `http.Client` for the API/DNS calls. Lighter than full-browser (one page load per session, not per call) and proven to work for reads. **Caveat (from the 2026-05-30 re-check):** Imperva's JA4/TLS signal means a Go `http.Client` exposes a non-Chrome TLS fingerprint; this passed for reads in this run, but if Imperva later challenges the HTTP path, fall back to in-browser `fetch` (the backend seam keeps both paths available). For write paths (NS delegation during migration), prefer in-browser to be safe. This refines the locked plan's Task 4 default; manifest unchanged (still one backend seam, both transports behind it), recorded here — no unlock needed. + +Minor follow-up: `setup-go@v5` emits a Node-20 deprecation warning (cutoff 2026-06-16) — bump the pinned action later. From 0bbc20bcc303e8137ad966b15597bf27ada9d985 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 18:16:33 -0400 Subject: [PATCH 16/20] refactor(hoverclient): add browser backend configuration seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce executionBackend interface routing Login/ListDomains/etc. to either httpBackend (injected http.Client — tests) or browserBackend (nil http.Client — production Chrome path). NewClientWithOptions parses explicit BrowserOptions; NewClient preserves backward-compat signature. Provider Initialize parses browser_path/download/headless/profile_dir config keys with HOVER_BROWSER_* env fallbacks via parseBrowserConfig. browserBackend live ops return ErrBrowserBackendUnavailable (Task 3). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/provider.go | 82 ++++++++++++++++++++++- internal/provider_test.go | 70 ++++++++++++++++++++ pkg/hoverclient/backend.go | 65 +++++++++++++++++++ pkg/hoverclient/browser_backend.go | 64 ++++++++++++++++++ pkg/hoverclient/client.go | 100 +++++++++++++++++++++++++---- pkg/hoverclient/client_test.go | 67 +++++++++++++++++++ pkg/hoverclient/options.go | 7 ++ 7 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 pkg/hoverclient/backend.go create mode 100644 pkg/hoverclient/browser_backend.go create mode 100644 pkg/hoverclient/options.go diff --git a/internal/provider.go b/internal/provider.go index 83bdff9..e29b3ec 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" @@ -38,6 +39,9 @@ type HoverProvider struct { // Defaults to client (which now satisfies hoverDomainLister); tests // override with a fakeHoverClient. domains hoverDomainLister + // browserOpts holds the resolved browser configuration for the client. + // Populated by Initialize; exposed for test assertions. + browserOpts hoverclient.BrowserOptions } var _ interfaces.IaCProvider = (*HoverProvider)(nil) @@ -56,8 +60,17 @@ func (p *HoverProvider) Version() string { return Version } // // Optional keys: // -// totp_secret — Base32-encoded TOTP seed (required if the account has MFA -// enabled; safe to omit when MFA is off) +// totp_secret — Base32-encoded TOTP seed (required if the account has +// MFA enabled; safe to omit when MFA is off) +// browser_path — Absolute path to the Chrome/Chromium binary. +// Falls back to HOVER_BROWSER_PATH env var, then auto-discovery. +// browser_download — Boolean; allow rod to download Chromium if none found. +// Falls back to HOVER_BROWSER_DOWNLOAD env var (default true). +// browser_headless — Boolean; run Chrome in headless mode. +// Falls back to HOVER_BROWSER_HEADLESS env var (default true). +// browser_profile_dir — Directory for the persistent Chrome profile (Imperva +// clearance cookies). Falls back to HOVER_BROWSER_PROFILE_DIR +// env var, then ${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile. func (p *HoverProvider) Initialize(ctx context.Context, config map[string]any) error { username, _ := config["username"].(string) password, _ := config["password"].(string) @@ -79,18 +92,24 @@ func (p *HoverProvider) Initialize(ctx context.Context, config map[string]any) e totpSecret = ts } + browserOpts, err := parseBrowserConfig(config) + if err != nil { + return fmt.Errorf("hover: browser config: %w", err) + } + creds := hoverclient.Credentials{ Username: username, Password: password, TOTPSecret: totpSecret, } - c, err := hoverclient.NewClient(creds, nil) + c, err := hoverclient.NewClientWithOptions(creds, nil, hoverclient.ClientOptions{Browser: browserOpts}) if err != nil { return fmt.Errorf("hover: client init: %w", err) } p.client = c p.domains = c + p.browserOpts = browserOpts p.drivers = map[string]interfaces.ResourceDriver{ "infra.dns": drivers.NewDNSDriver(c), "infra.dns_delegation": drivers.NewDelegationDriver(c), @@ -346,3 +365,60 @@ func isNotFound(err error) bool { msg := err.Error() return strings.Contains(msg, "not found") || strings.Contains(msg, "404") } + +// parseBrowserConfig builds BrowserOptions from the provider config map, +// falling back to HOVER_BROWSER_* env vars (via BrowserOptionsFromEnv), +// and then to DefaultBrowserOptions. Explicit config keys take precedence +// over env vars; env vars take precedence over defaults. +// +// Supported config keys: +// - browser_path (string) +// - browser_download (bool or "true"/"false" string) +// - browser_headless (bool or "true"/"false" string) +// - browser_profile_dir (string) +func parseBrowserConfig(config map[string]any) (hoverclient.BrowserOptions, error) { + // Start from env-var layer (which itself falls back to defaults). + opts, err := hoverclient.BrowserOptionsFromEnv() + if err != nil { + return hoverclient.BrowserOptions{}, err + } + + // Explicit config keys override env vars. + if v, ok := config["browser_path"].(string); ok && strings.TrimSpace(v) != "" { + opts.Path = strings.TrimSpace(v) + } + if v, ok := config["browser_profile_dir"].(string); ok && strings.TrimSpace(v) != "" { + opts.ProfileDir = strings.TrimSpace(v) + } + if v, ok := config["browser_download"]; ok { + b, err := parseBoolConfig("browser_download", v) + if err != nil { + return hoverclient.BrowserOptions{}, err + } + opts.Download = b + } + if v, ok := config["browser_headless"]; ok { + b, err := parseBoolConfig("browser_headless", v) + if err != nil { + return hoverclient.BrowserOptions{}, err + } + opts.Headless = b + } + return opts, nil +} + +// parseBoolConfig converts a config value (bool or string) to bool. +func parseBoolConfig(key string, v any) (bool, error) { + switch val := v.(type) { + case bool: + return val, nil + case string: + b, err := strconv.ParseBool(strings.TrimSpace(val)) + if err != nil { + return false, fmt.Errorf("%s must be boolean: %w", key, err) + } + return b, nil + default: + return false, fmt.Errorf("%s must be boolean, got %T", key, v) + } +} diff --git a/internal/provider_test.go b/internal/provider_test.go index ea2ad10..c8cbcc9 100644 --- a/internal/provider_test.go +++ b/internal/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "os" "sync/atomic" "testing" @@ -199,3 +200,72 @@ func TestHoverProvider_EnumerateAll_DNS_skipsBlankName(t *testing.T) { t.Fatalf("want 1 entry with ProviderID=real.test; got %+v", out) } } + +// ── browser config tests ────────────────────────────────────────────────────── + +// TestInitialize_ParsesBrowserConfig verifies that browser_path, browser_download, +// browser_headless, and browser_profile_dir config keys are parsed and passed +// to the client. +func TestInitialize_ParsesBrowserConfig(t *testing.T) { + p := NewHoverProvider() + if err := p.Initialize(context.Background(), map[string]any{ + "username": "user@example.com", + "password": "password", + "browser_path": "/usr/bin/chromium", + "browser_download": false, + "browser_headless": false, + "browser_profile_dir": "/tmp/test-profile", + }); err != nil { + t.Fatalf("Initialize: %v", err) + } + opts := p.browserOpts + if opts.Path != "/usr/bin/chromium" { + t.Errorf("Path = %q, want /usr/bin/chromium", opts.Path) + } + if opts.Download != false { + t.Errorf("Download = %v, want false", opts.Download) + } + if opts.Headless != false { + t.Errorf("Headless = %v, want false", opts.Headless) + } + if opts.ProfileDir != "/tmp/test-profile" { + t.Errorf("ProfileDir = %q, want /tmp/test-profile", opts.ProfileDir) + } +} + +// TestInitialize_EnvBrowserConfigAliases verifies that HOVER_BROWSER_* env +// vars are read as fallbacks when the config map omits browser_* keys. +func TestInitialize_EnvBrowserConfigAliases(t *testing.T) { + t.Setenv("HOVER_BROWSER_PATH", "/env/chrome") + t.Setenv("HOVER_BROWSER_HEADLESS", "false") + t.Setenv("HOVER_BROWSER_DOWNLOAD", "false") + t.Setenv("HOVER_BROWSER_PROFILE_DIR", "/env/profile") + defer func() { + os.Unsetenv("HOVER_BROWSER_PATH") + os.Unsetenv("HOVER_BROWSER_HEADLESS") + os.Unsetenv("HOVER_BROWSER_DOWNLOAD") + os.Unsetenv("HOVER_BROWSER_PROFILE_DIR") + }() + + p := NewHoverProvider() + if err := p.Initialize(context.Background(), map[string]any{ + "username": "user@example.com", + "password": "password", + // no browser_* config keys — env vars should be used + }); err != nil { + t.Fatalf("Initialize: %v", err) + } + opts := p.browserOpts + if opts.Path != "/env/chrome" { + t.Errorf("Path = %q, want /env/chrome", opts.Path) + } + if opts.Headless != false { + t.Errorf("Headless = %v, want false", opts.Headless) + } + if opts.Download != false { + t.Errorf("Download = %v, want false", opts.Download) + } + if opts.ProfileDir != "/env/profile" { + t.Errorf("ProfileDir = %q, want /env/profile", opts.ProfileDir) + } +} diff --git a/pkg/hoverclient/backend.go b/pkg/hoverclient/backend.go new file mode 100644 index 0000000..0b60c77 --- /dev/null +++ b/pkg/hoverclient/backend.go @@ -0,0 +1,65 @@ +package hoverclient + +import "context" + +// executionBackend is the internal seam that routes login and API calls to +// either the HTTP backend (testing, injected http.Client) or the browser +// backend (production, Chrome-driven Imperva clearance + login). +// +// All methods receive *Client so the backend can read credentials, the cookie +// jar, and internal fields without duplicating them. +type executionBackend interface { + Login(ctx context.Context, c *Client) error + ListDomains(ctx context.Context, c *Client) ([]Domain, error) + GetDomain(ctx context.Context, c *Client, domain string) (*Domain, error) + ListRecords(ctx context.Context, c *Client, domain string) ([]DNSRecord, error) + CreateRecord(ctx context.Context, c *Client, domainID string, rec DNSRecord) (*DNSRecord, error) + UpdateRecord(ctx context.Context, c *Client, recordID string, rec DNSRecord) error + DeleteRecord(ctx context.Context, c *Client, recordID string) error + GetDomainDelegation(ctx context.Context, c *Client, domainName string) (*DomainDelegation, error) + SetNameservers(ctx context.Context, c *Client, domainName string, ns []string) error + Close() error +} + +// httpBackend implements executionBackend by delegating to the *Client's +// existing HTTP methods. It is selected when an *http.Client is injected +// (tests and internal callers that manage their own HTTP client). +type httpBackend struct{} + +func (h *httpBackend) Login(ctx context.Context, c *Client) error { + return c.ensureLogin(ctx) +} + +func (h *httpBackend) ListDomains(ctx context.Context, c *Client) ([]Domain, error) { + return c.listDomainsHTTP(ctx) +} + +func (h *httpBackend) GetDomain(ctx context.Context, c *Client, domain string) (*Domain, error) { + return c.getDomainHTTP(ctx, domain) +} + +func (h *httpBackend) ListRecords(ctx context.Context, c *Client, domain string) ([]DNSRecord, error) { + return c.listRecordsHTTP(ctx, domain) +} + +func (h *httpBackend) CreateRecord(ctx context.Context, c *Client, domainID string, rec DNSRecord) (*DNSRecord, error) { + return c.createRecordHTTP(ctx, domainID, rec) +} + +func (h *httpBackend) UpdateRecord(ctx context.Context, c *Client, recordID string, rec DNSRecord) error { + return c.updateRecordHTTP(ctx, recordID, rec) +} + +func (h *httpBackend) DeleteRecord(ctx context.Context, c *Client, recordID string) error { + return c.deleteRecordHTTP(ctx, recordID) +} + +func (h *httpBackend) GetDomainDelegation(ctx context.Context, c *Client, domainName string) (*DomainDelegation, error) { + return c.getDomainDelegationHTTP(ctx, domainName) +} + +func (h *httpBackend) SetNameservers(ctx context.Context, c *Client, domainName string, ns []string) error { + return c.setNameserversHTTP(ctx, domainName, ns) +} + +func (h *httpBackend) Close() error { return nil } diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go new file mode 100644 index 0000000..d044482 --- /dev/null +++ b/pkg/hoverclient/browser_backend.go @@ -0,0 +1,64 @@ +package hoverclient + +import ( + "context" + "errors" +) + +// ErrBrowserBackendUnavailable is returned by browserBackend live operations +// before the full browser login implementation is wired (Task 3). Callers +// should treat this as a "not yet implemented via browser" signal. +var ErrBrowserBackendUnavailable = errors.New("hover: browser backend not yet available for this operation (Task 3)") + +// browserBackend implements executionBackend using a Chrome instance driven +// via the GoCodeAlone/rod CDP library. Login mints Imperva clearance cookies +// and completes TOTP 2FA in-browser; subsequent read operations reuse those +// cookies via the Go http.Client (hybrid architecture). +// +// Live operations currently return ErrBrowserBackendUnavailable — the full +// browser login flow is implemented in Task 3. +type browserBackend struct { + opts BrowserOptions +} + +func newBrowserBackend(opts BrowserOptions) *browserBackend { + return &browserBackend{opts: opts} +} + +func (b *browserBackend) Login(_ context.Context, _ *Client) error { + return ErrBrowserBackendUnavailable +} + +func (b *browserBackend) ListDomains(_ context.Context, _ *Client) ([]Domain, error) { + return nil, ErrBrowserBackendUnavailable +} + +func (b *browserBackend) GetDomain(_ context.Context, _ *Client, _ string) (*Domain, error) { + return nil, ErrBrowserBackendUnavailable +} + +func (b *browserBackend) ListRecords(_ context.Context, _ *Client, _ string) ([]DNSRecord, error) { + return nil, ErrBrowserBackendUnavailable +} + +func (b *browserBackend) CreateRecord(_ context.Context, _ *Client, _ string, _ DNSRecord) (*DNSRecord, error) { + return nil, ErrBrowserBackendUnavailable +} + +func (b *browserBackend) UpdateRecord(_ context.Context, _ *Client, _ string, _ DNSRecord) error { + return ErrBrowserBackendUnavailable +} + +func (b *browserBackend) DeleteRecord(_ context.Context, _ *Client, _ string) error { + return ErrBrowserBackendUnavailable +} + +func (b *browserBackend) GetDomainDelegation(_ context.Context, _ *Client, _ string) (*DomainDelegation, error) { + return nil, ErrBrowserBackendUnavailable +} + +func (b *browserBackend) SetNameservers(_ context.Context, _ *Client, _ string, _ []string) error { + return ErrBrowserBackendUnavailable +} + +func (b *browserBackend) Close() error { return nil } diff --git a/pkg/hoverclient/client.go b/pkg/hoverclient/client.go index a83ab91..3ce4645 100644 --- a/pkg/hoverclient/client.go +++ b/pkg/hoverclient/client.go @@ -37,31 +37,64 @@ type Client struct { creds Credentials loggedAt time.Time UserAgent string + backend executionBackend } -// NewClient returns a fresh Client. Pass http=nil for an internal -// jar-backed http.Client. Tests inject a stub to redirect requests. +// NewClient returns a fresh Client. Pass httpClient=nil for the browser +// backend (production path — Chrome drives Imperva clearance + login). +// Pass a non-nil *http.Client to select the HTTP backend; tests inject +// a stub to redirect requests without launching Chrome. func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) { + return NewClientWithOptions(creds, httpClient, ClientOptions{}) +} + +// NewClientWithOptions returns a Client with explicit runtime options. +// opts.Browser is used when httpClient is nil (browser backend); it is +// ignored when httpClient is non-nil (HTTP backend selected). +func NewClientWithOptions(creds Credentials, httpClient *http.Client, opts ClientOptions) (*Client, error) { creds.Username = strings.TrimSpace(creds.Username) creds.Password = strings.TrimRight(creds.Password, "\r\n") if creds.Username == "" || creds.Password == "" { return nil, errors.New("hover: username + password required") } - if httpClient == nil { + + var backend executionBackend + if httpClient != nil { + // Injected HTTP client: use HTTP backend (test path). + if httpClient.Jar == nil { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + httpClient.Jar = jar + } + backend = &httpBackend{} + } else { + // nil HTTP client: use browser backend (production path). + browserOpts := opts.Browser + if browserOpts.ProfileDir == "" { + browserOpts.ProfileDir = defaultBrowserProfileDir() + } + if browserOpts.Timeout == 0 { + browserOpts.Timeout = defaultBrowserTimeout + } + backend = newBrowserBackend(browserOpts) + + // Provide a jar-backed http.Client for the cookie-reuse path + // (Task 3 will populate this jar from browser cookies). jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("hover: cookie jar: %w", err) } httpClient = &http.Client{Jar: jar, Timeout: 30 * time.Second} } - if httpClient.Jar == nil { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, err - } - httpClient.Jar = jar - } - return &Client{http: httpClient, creds: creds, UserAgent: defaultUserAgent}, nil + + return &Client{ + http: httpClient, + creds: creds, + UserAgent: defaultUserAgent, + backend: backend, + }, nil } // Login performs a full authentication cycle against Hover's control panel. @@ -69,13 +102,14 @@ func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) { // when the session is older than sessionStaleAfter (1 hour). Safe for // concurrent use; the internal mutex serialises calls. // -// The underlying auth flow follows Hover's current React signin UI: +// On the HTTP backend the underlying auth flow follows Hover's current React +// signin UI: // 1. POST https://www.hover.com/signin/auth.json with username + password. // 2. If the response status is "need_2fa", POST /signin/auth2.json with the // current TOTP code. // 3. Session cookies are stored in the jar for subsequent API calls. func (c *Client) Login(ctx context.Context) error { - return c.ensureLogin(ctx) + return c.backend.Login(ctx, c) } // ensureLogin re-authenticates iff the session is stale. Safe to call @@ -288,6 +322,11 @@ type Domain struct { // re-apply thrash failure mode (empty → Diff says NeedsUpdate forever) // into a single-iteration error visible at first wfctl plan. func (c *Client) GetDomainDelegation(ctx context.Context, domainName string) (*DomainDelegation, error) { + return c.backend.GetDomainDelegation(ctx, c, domainName) +} + +// getDomainDelegationHTTP is the HTTP-backend implementation of GetDomainDelegation. +func (c *Client) getDomainDelegationHTTP(ctx context.Context, domainName string) (*DomainDelegation, error) { if err := c.ensureLogin(ctx); err != nil { return nil, err } @@ -335,6 +374,11 @@ func (c *Client) GetDomainDelegation(ctx context.Context, domainName string) (*D // resource). Future: cache CSRF at session granularity if // mixed-resource throughput becomes a concern. func (c *Client) SetNameservers(ctx context.Context, domainName string, ns []string) error { + return c.backend.SetNameservers(ctx, c, domainName, ns) +} + +// setNameserversHTTP is the HTTP-backend implementation of SetNameservers. +func (c *Client) setNameserversHTTP(ctx context.Context, domainName string, ns []string) error { c.mu.Lock() defer c.mu.Unlock() if err := c.ensureLoginLocked(ctx); err != nil { @@ -387,6 +431,11 @@ func (c *Client) putNameserversLocked(ctx context.Context, domainName string, ns // CSRF is not required for GET requests under Hover's API; ensureLogin // is still called so the session cookie is fresh. func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + return c.backend.ListDomains(ctx, c) +} + +// listDomainsHTTP is the HTTP-backend implementation of ListDomains. +func (c *Client) listDomainsHTTP(ctx context.Context) ([]Domain, error) { if err := c.ensureLogin(ctx); err != nil { return nil, fmt.Errorf("hover: ListDomains: login: %w", err) } @@ -424,6 +473,11 @@ func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { // creating new records via CreateRecord; the human-readable name is // not accepted by the POST /api/dns endpoint. func (c *Client) GetDomain(ctx context.Context, domain string) (*Domain, error) { + return c.backend.GetDomain(ctx, c, domain) +} + +// getDomainHTTP is the HTTP-backend implementation of GetDomain. +func (c *Client) getDomainHTTP(ctx context.Context, domain string) (*Domain, error) { if err := c.ensureLogin(ctx); err != nil { return nil, err } @@ -456,6 +510,11 @@ func (c *Client) GetDomain(ctx context.Context, domain string) (*Domain, error) // ListRecords returns records for the named zone. Caller MUST pass // the apex domain (e.g. "example.com"). func (c *Client) ListRecords(ctx context.Context, domain string) ([]DNSRecord, error) { + return c.backend.ListRecords(ctx, c, domain) +} + +// listRecordsHTTP is the HTTP-backend implementation of ListRecords. +func (c *Client) listRecordsHTTP(ctx context.Context, domain string) ([]DNSRecord, error) { if err := c.ensureLogin(ctx); err != nil { return nil, err } @@ -487,6 +546,11 @@ func (c *Client) ListRecords(ctx context.Context, domain string) ([]DNSRecord, e // CreateRecord adds a new DNS record for the domain. func (c *Client) CreateRecord(ctx context.Context, domainID string, rec DNSRecord) (*DNSRecord, error) { + return c.backend.CreateRecord(ctx, c, domainID, rec) +} + +// createRecordHTTP is the HTTP-backend implementation of CreateRecord. +func (c *Client) createRecordHTTP(ctx context.Context, domainID string, rec DNSRecord) (*DNSRecord, error) { if err := c.ensureLogin(ctx); err != nil { return nil, err } @@ -523,6 +587,11 @@ func (c *Client) CreateRecord(ctx context.Context, domainID string, rec DNSRecor // UpdateRecord PATCHes an existing record's content (and TTL when > 0). func (c *Client) UpdateRecord(ctx context.Context, recordID string, rec DNSRecord) error { + return c.backend.UpdateRecord(ctx, c, recordID, rec) +} + +// updateRecordHTTP is the HTTP-backend implementation of UpdateRecord. +func (c *Client) updateRecordHTTP(ctx context.Context, recordID string, rec DNSRecord) error { if err := c.ensureLogin(ctx); err != nil { return err } @@ -548,6 +617,11 @@ func (c *Client) UpdateRecord(ctx context.Context, recordID string, rec DNSRecor // DeleteRecord removes a record by ID. func (c *Client) DeleteRecord(ctx context.Context, recordID string) error { + return c.backend.DeleteRecord(ctx, c, recordID) +} + +// deleteRecordHTTP is the HTTP-backend implementation of DeleteRecord. +func (c *Client) deleteRecordHTTP(ctx context.Context, recordID string) error { if err := c.ensureLogin(ctx); err != nil { return err } diff --git a/pkg/hoverclient/client_test.go b/pkg/hoverclient/client_test.go index 77db5d2..e8b7e1f 100644 --- a/pkg/hoverclient/client_test.go +++ b/pkg/hoverclient/client_test.go @@ -697,3 +697,70 @@ func TestExtractCSRFMeta_AttributeOrders(t *testing.T) { } } } + +// ── backend-seam tests ──────────────────────────────────────────────────────── + +// TestNewClient_InjectedHTTPUsesHTTPBackendForTests verifies that passing a +// non-nil *http.Client selects the HTTP backend so existing httptest stubs +// work without Chrome. +func TestNewClient_InjectedHTTPUsesHTTPBackendForTests(t *testing.T) { + jar, _ := cookiejar.New(nil) + httpC := &http.Client{Jar: jar} + creds := Credentials{Username: "alice", Password: "pw"} + c, err := NewClient(creds, httpC) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, ok := c.backend.(*httpBackend) + if !ok { + t.Fatalf("want *httpBackend when httpClient is injected; got %T", c.backend) + } +} + +// TestNewClient_DefaultsToBrowserBackendWithoutInjectedHTTP verifies that +// passing nil selects the browser backend (for production use with Chrome). +func TestNewClient_DefaultsToBrowserBackendWithoutInjectedHTTP(t *testing.T) { + creds := Credentials{Username: "alice", Password: "pw"} + c, err := NewClient(creds, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, ok := c.backend.(*browserBackend) + if !ok { + t.Fatalf("want *browserBackend when httpClient is nil; got %T", c.backend) + } +} + +// TestNewClientWithOptions_PreservesExplicitBrowserConfig verifies that +// explicit ClientOptions.Browser values survive NewClientWithOptions. +func TestNewClientWithOptions_PreservesExplicitBrowserConfig(t *testing.T) { + creds := Credentials{Username: "alice", Password: "pw"} + opts := ClientOptions{ + Browser: BrowserOptions{ + Path: "/custom/chrome", + Download: false, + Headless: false, + ProfileDir: "/custom/profile", + }, + } + c, err := NewClientWithOptions(creds, nil, opts) + if err != nil { + t.Fatalf("NewClientWithOptions: %v", err) + } + bb, ok := c.backend.(*browserBackend) + if !ok { + t.Fatalf("want *browserBackend; got %T", c.backend) + } + if bb.opts.Path != "/custom/chrome" { + t.Errorf("Path = %q, want /custom/chrome", bb.opts.Path) + } + if bb.opts.Download != false { + t.Errorf("Download = %v, want false", bb.opts.Download) + } + if bb.opts.Headless != false { + t.Errorf("Headless = %v, want false", bb.opts.Headless) + } + if bb.opts.ProfileDir != "/custom/profile" { + t.Errorf("ProfileDir = %q, want /custom/profile", bb.opts.ProfileDir) + } +} diff --git a/pkg/hoverclient/options.go b/pkg/hoverclient/options.go new file mode 100644 index 0000000..1db5f1f --- /dev/null +++ b/pkg/hoverclient/options.go @@ -0,0 +1,7 @@ +package hoverclient + +// ClientOptions configures a Client beyond credentials. +// Zero value is valid; individual fields fall back to defaults. +type ClientOptions struct { + Browser BrowserOptions +} From 693ce9f7bcd2d9c3bb70091ed0c08341c9af892c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 18:25:34 -0400 Subject: [PATCH 17/20] feat(hoverclient): browser login mints Imperva clearance + hands cookies to HTTP reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3: implement browserBackend.Login (Chrome launch via launchBrowserWithHandles, navigator.webdriver strip, UA/AcceptLanguage stealth, waitForClearanceCookies, submitBrowserSignin with TOTP, cookie handoff to c.http.Jar), typed errors (ErrBotChallenge / ErrChromeUnavailable / ErrEmail2FARequired), read delegation (ListDomains/GetDomain/ListRecords/GetDomainDelegation → HTTP backend after login), writes still return ErrBrowserBackendUnavailable (Task 4). Ten new tests in browser_backend_test.go drive real go-rod against local httptest servers. Add .hover-browser-profile/ to .gitignore. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + pkg/hoverclient/browser_backend.go | 347 +++++++++++++++- pkg/hoverclient/browser_backend_test.go | 508 ++++++++++++++++++++++++ 3 files changed, 838 insertions(+), 19 deletions(-) create mode 100644 pkg/hoverclient/browser_backend_test.go diff --git a/.gitignore b/.gitignore index c9b2b08..5dde567 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .claude/ .release/ dist/ + +.hover-browser-profile/ diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index d044482..edda99d 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -3,44 +3,287 @@ package hoverclient import ( "context" "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/GoCodeAlone/rod" + rodlauncher "github.com/GoCodeAlone/rod/lib/launcher" + "github.com/GoCodeAlone/rod/lib/proto" ) -// ErrBrowserBackendUnavailable is returned by browserBackend live operations -// before the full browser login implementation is wired (Task 3). Callers -// should treat this as a "not yet implemented via browser" signal. -var ErrBrowserBackendUnavailable = errors.New("hover: browser backend not yet available for this operation (Task 3)") +// --------------------------------------------------------------------------- +// Typed errors +// --------------------------------------------------------------------------- + +// ErrBrowserBackendUnavailable is returned by browserBackend write operations +// that are not yet implemented (Task 4). Callers should treat this as a +// "not yet implemented via browser" signal. +var ErrBrowserBackendUnavailable = errors.New("hover: browser backend not yet available for this operation (Task 4)") + +// ErrBotChallenge is returned when Imperva (or another bot-protection layer) +// blocks the browser session: clearance cookies never arrive, or the login +// endpoint returns a persistent access-denied response. Operators should +// check network egress rules or rotate the browser profile. +var ErrBotChallenge = errors.New("hover: Imperva/bot challenge blocked the browser session — check network or rotate browser profile") + +// ErrChromeUnavailable is returned when no Chrome binary can be found and +// BrowserOptions.Download is false. Install Chrome or set +// HOVER_BROWSER_DOWNLOAD=true to enable automatic download. +var ErrChromeUnavailable = errors.New("hover: no Chrome binary found; install Chrome or set HOVER_BROWSER_DOWNLOAD=true") + +// ErrEmail2FARequired is returned when Hover reports need_2fa but no TOTP +// secret is configured on the Credentials. This means the account uses +// email-OTP or another non-TOTP second factor. Configure a TOTP authenticator +// app on the account and supply the base32 seed as totp_secret. +var ErrEmail2FARequired = errors.New("hover: account uses email/non-TOTP 2FA — configure an authenticator app (TOTP) on the account and supply totp_secret, or pre-trust this browser profile") + +// --------------------------------------------------------------------------- +// browserBackend +// --------------------------------------------------------------------------- // browserBackend implements executionBackend using a Chrome instance driven // via the GoCodeAlone/rod CDP library. Login mints Imperva clearance cookies // and completes TOTP 2FA in-browser; subsequent read operations reuse those // cookies via the Go http.Client (hybrid architecture). // -// Live operations currently return ErrBrowserBackendUnavailable — the full -// browser login flow is implemented in Task 3. +// The browser and launcher handles are kept alive after Login so that Task 4 +// can reuse the same page for in-browser writes. Close() tears them down. type browserBackend struct { opts BrowserOptions + + // overrideHost replaces hoverHost for local tests. Empty means production + // (uses hoverHost). Never set in production code. + overrideHost string + + // Live handles, set by Login and torn down by Close. + browser *rod.Browser + launcher *rodlauncher.Launcher } func newBrowserBackend(opts BrowserOptions) *browserBackend { return &browserBackend{opts: opts} } -func (b *browserBackend) Login(_ context.Context, _ *Client) error { - return ErrBrowserBackendUnavailable +// signinHost returns the host to navigate to. In production this is hoverHost; +// in tests it is overrideHost. +func (b *browserBackend) signinHost() string { + if b.overrideHost != "" { + return b.overrideHost + } + return hoverHost } -func (b *browserBackend) ListDomains(_ context.Context, _ *Client) ([]Domain, error) { - return nil, ErrBrowserBackendUnavailable +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- + +// Login drives Chrome to: +// 1. Navigate to /signin (Imperva JS runs, clearance cookies are minted). +// 2. Wait for Imperva clearance cookies. +// 3. Submit credentials via in-page fetch (same-origin XHR path). +// 4. Handle TOTP 2FA if required. +// 5. Copy all browser cookies into c.http.Jar for the hybrid HTTP reads. +// 6. Set c.loggedAt. +func (b *browserBackend) Login(ctx context.Context, c *Client) error { + c.mu.Lock() + alreadyFresh := !c.loggedAt.IsZero() && time.Since(c.loggedAt) < sessionStaleAfter + c.mu.Unlock() + if alreadyFresh { + return nil + } + + if b.opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, b.opts.Timeout) + defer cancel() + } + + // Resolve Chrome binary. If not found and Download is false → typed error. + if b.opts.Path == "" { + if _, ok := findChromeBinary(); !ok && !b.opts.Download { + return ErrChromeUnavailable + } + } else { + // Explicit path provided: validate it exists (unless Download would + // handle it, but an explicit path means the operator chose a specific + // binary — honor it literally). + if _, err := os.Stat(b.opts.Path); err != nil && !b.opts.Download { + return fmt.Errorf("%w: %s: %v", ErrChromeUnavailable, b.opts.Path, err) + } + } + + browser, l, err := b.launchBrowser(ctx) + if err != nil { + // launchBrowserWithHandles returns a plain error when Chrome isn't found; + // wrap as ErrChromeUnavailable if it mentions Chrome. + msg := err.Error() + if strings.Contains(msg, "no Chrome") || strings.Contains(msg, "launch Chrome") || strings.Contains(msg, "connect Chrome") { + return fmt.Errorf("%w: %v", ErrChromeUnavailable, err) + } + return err + } + // Keep handles for Close() and Task 4 page reuse. + b.browser = browser + b.launcher = l + + page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + return fmt.Errorf("hover browser login: new page: %w", err) + } + defer func() { _ = page.Close() }() + page = page.Context(ctx) + + // Strip navigator.webdriver fingerprint. + _, _ = page.EvalOnNewDocument(`() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }`) + + // Set a consistent UA + Accept-Language (matching the Chrome build we launched). + _ = page.SetUserAgent(&proto.NetworkSetUserAgentOverride{ + UserAgent: defaultUserAgent, + AcceptLanguage: "en-US,en;q=0.9", + Platform: "macOS", + }) + + // Navigate to signin. + signinURL := b.signinHost() + "/signin" + if err := page.Navigate(signinURL); err != nil { + return fmt.Errorf("hover browser login: navigate signin: %w", err) + } + if err := page.WaitLoad(); err != nil { + return fmt.Errorf("hover browser login: wait signin load: %w", err) + } + + // Wait for Imperva clearance cookies. If they never arrive → bot challenge. + if _, err := waitForClearanceCookies(ctx, browser); err != nil { + if isContextErr(err) { + return fmt.Errorf("%w: %v", ErrBotChallenge, err) + } + return fmt.Errorf("%w: %v", ErrBotChallenge, err) + } + + // Submit credentials via in-page fetch. The probe helper handles need_2fa + // detection and TOTP; we post-process its errors to surface typed variants. + if err := submitBrowserSignin(ctx, page, c.creds); err != nil { + return b.classifySigninError(err) + } + + // Copy all browser cookies into the Go http.Client jar so HTTP reads reuse + // the Imperva clearance + session cookies (hybrid architecture). + if err := b.handOffCookies(browser, c); err != nil { + return fmt.Errorf("hover browser login: cookie handoff: %w", err) + } + + c.mu.Lock() + c.loggedAt = time.Now() + c.mu.Unlock() + return nil } -func (b *browserBackend) GetDomain(_ context.Context, _ *Client, _ string) (*Domain, error) { - return nil, ErrBrowserBackendUnavailable +// classifySigninError maps submitBrowserSignin errors to typed errors where +// appropriate. The probe helper already returns clear English strings; we +// wrap them into the appropriate sentinel so callers can errors.Is them. +func (b *browserBackend) classifySigninError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + // need_2fa without TOTP secret — emit the dedicated typed error. + if strings.Contains(msg, "no totp_secret") || strings.Contains(msg, "totp_secret was provided") { + return fmt.Errorf("%w: %v", ErrEmail2FARequired, err) + } + // HTTP 401/403 from auth endpoint → bot challenge. + if strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403") { + return fmt.Errorf("%w: %v", ErrBotChallenge, err) + } + return err } -func (b *browserBackend) ListRecords(_ context.Context, _ *Client, _ string) ([]DNSRecord, error) { - return nil, ErrBrowserBackendUnavailable +// launchBrowser launches Chrome and returns the browser + launcher handles. +// It honours HOVER_BROWSER_NO_SANDBOX when set. --no-sandbox is intentionally +// not on by default (it weakens sandbox security); it is only applied when an +// operator explicitly sets the env var. +func (b *browserBackend) launchBrowser(ctx context.Context) (*rod.Browser, *rodlauncher.Launcher, error) { + return launchBrowserWithHandles(ctx, b.opts) } +// handOffCookies copies every cookie the browser holds into c.http.Jar for +// the signinHost. This lets the Go http.Client reuse Imperva clearance +// and session cookies for all subsequent read operations. +func (b *browserBackend) handOffCookies(browser *rod.Browser, c *Client) error { + cookies, err := browser.GetCookies() + if err != nil { + return fmt.Errorf("get browser cookies: %w", err) + } + // Determine the jar target URL. In production this is hoverHost; in tests + // it is overrideHost (the fake httptest server). + targetURLStr := b.signinHost() + targetURL, err := url.Parse(targetURLStr) + if err != nil { + return fmt.Errorf("parse host URL %q: %w", targetURLStr, err) + } + + var httpCookies []*http.Cookie + for _, cookie := range cookies { + httpCookies = append(httpCookies, &http.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + Path: cookie.Path, + Domain: cookie.Domain, + Secure: cookie.Secure, + HttpOnly: cookie.HTTPOnly, + }) + } + if c.http.Jar != nil { + c.http.Jar.SetCookies(targetURL, httpCookies) + } + return nil +} + +// --------------------------------------------------------------------------- +// READ operations — delegate to the HTTP backend after ensuring login. +// --------------------------------------------------------------------------- + +func (b *browserBackend) ensureLoggedIn(ctx context.Context, c *Client) error { + return b.Login(ctx, c) +} + +func (b *browserBackend) ListDomains(ctx context.Context, c *Client) ([]Domain, error) { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return nil, err + } + return c.listDomainsHTTP(ctx) +} + +func (b *browserBackend) GetDomain(ctx context.Context, c *Client, domain string) (*Domain, error) { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return nil, err + } + return c.getDomainHTTP(ctx, domain) +} + +func (b *browserBackend) ListRecords(ctx context.Context, c *Client, domain string) ([]DNSRecord, error) { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return nil, err + } + return c.listRecordsHTTP(ctx, domain) +} + +func (b *browserBackend) GetDomainDelegation(ctx context.Context, c *Client, domainName string) (*DomainDelegation, error) { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return nil, err + } + return c.getDomainDelegationHTTP(ctx, domainName) +} + +// --------------------------------------------------------------------------- +// WRITE operations — Task 4 (not yet implemented, return sentinel). +// --------------------------------------------------------------------------- + func (b *browserBackend) CreateRecord(_ context.Context, _ *Client, _ string, _ DNSRecord) (*DNSRecord, error) { return nil, ErrBrowserBackendUnavailable } @@ -53,12 +296,78 @@ func (b *browserBackend) DeleteRecord(_ context.Context, _ *Client, _ string) er return ErrBrowserBackendUnavailable } -func (b *browserBackend) GetDomainDelegation(_ context.Context, _ *Client, _ string) (*DomainDelegation, error) { - return nil, ErrBrowserBackendUnavailable -} - func (b *browserBackend) SetNameservers(_ context.Context, _ *Client, _ string, _ []string) error { return ErrBrowserBackendUnavailable } -func (b *browserBackend) Close() error { return nil } +// --------------------------------------------------------------------------- +// Close +// --------------------------------------------------------------------------- + +// Close tears down the browser process and CDP launcher. The profile directory +// is preserved (Kill, not Cleanup) so Imperva clearance cookies persist across +// calls. Safe to call multiple times. +func (b *browserBackend) Close() error { + if b.browser != nil { + _ = b.browser.Close() + b.browser = nil + } + if b.launcher != nil { + b.launcher.Kill() + b.launcher = nil + } + return nil +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// isContextErr returns true when the error is a context cancellation or +// deadline exceeded. +func isContextErr(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +// launchBrowserWithHandles launches Chrome and returns the rod.Browser and +// *rodlauncher.Launcher so the caller can manage the full lifecycle (including +// Kill on teardown without deleting the profile dir). +// +// We re-implement the launch sequence rather than delegating to +// launchProbeBrowser because that function encapsulates the launcher handle +// inside the cleanup closure, giving callers no way to store it. +func launchBrowserWithHandles(ctx context.Context, opts BrowserOptions) (*rod.Browser, *rodlauncher.Launcher, error) { + if err := os.MkdirAll(opts.ProfileDir, 0o700); err != nil { + return nil, nil, fmt.Errorf("hover browser: create profile dir: %w", err) + } + + noSandbox := os.Getenv("HOVER_BROWSER_NO_SANDBOX") == "true" + + l := rodlauncher.New(). + Context(ctx). + HeadlessNew(opts.Headless). + UserDataDir(opts.ProfileDir) + + if noSandbox { + l = l.Set("no-sandbox") + } + + if opts.Path != "" { + l = l.Bin(opts.Path) + } else if path, ok := findChromeBinary(); ok { + l = l.Bin(path) + } else if !opts.Download { + return nil, nil, fmt.Errorf("hover browser: no Chrome found; install Chrome or set HOVER_BROWSER_DOWNLOAD=true") + } + + controlURL, err := l.Launch() + if err != nil { + return nil, nil, fmt.Errorf("hover browser: launch Chrome: %w", err) + } + browser := rod.New().Context(ctx).ControlURL(controlURL) + if err := browser.Connect(); err != nil { + l.Kill() + return nil, nil, fmt.Errorf("hover browser: connect Chrome: %w", err) + } + return browser, l, nil +} diff --git a/pkg/hoverclient/browser_backend_test.go b/pkg/hoverclient/browser_backend_test.go new file mode 100644 index 0000000..73c1c04 --- /dev/null +++ b/pkg/hoverclient/browser_backend_test.go @@ -0,0 +1,508 @@ +package hoverclient + +// browser_backend_test.go — TDD tests for Task 3: browserBackend.Login, +// cookie handoff, and typed error surface. +// +// All tests that exercise go-rod drive it against a local httptest server +// (no real Hover connection). Tests run headless with an isolated temp +// profile dir so they never interfere with a real browser session. + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" +) + +// -------------------------------------------------------------------------- +// Shared helpers +// -------------------------------------------------------------------------- + +// newBrowserTestOpts returns headless BrowserOptions pointing at a temp profile +// and with a short timeout suitable for local tests. It skips the test if no +// Chrome binary is available and opts.Download is false. +func newBrowserTestOpts(t *testing.T) BrowserOptions { + t.Helper() + dir := t.TempDir() + opts := BrowserOptions{ + Download: false, + Headless: true, + ProfileDir: dir, + Timeout: 60 * time.Second, + } + // If a Chrome binary exists, use it; otherwise skip (we don't download in CI). + if _, ok := findChromeBinary(); !ok { + t.Skip("no Chrome binary found; skipping browser backend test (install Chrome to run)") + } + return opts +} + +// fakeSigninMux returns an http.ServeMux that simulates Hover's signin flow. +// authResponse is what /signin/auth.json returns. +// auth2Response is what /signin/auth2.json returns (ignored if authResponse +// does not contain status=need_2fa). +// It also serves a minimal /signin page with fake __uzma clearance cookies. +func fakeSigninMux(authResponse, auth2Response map[string]any) *http.ServeMux { + mux := http.NewServeMux() + + // Signin page — set clearance cookies so waitForClearanceCookies is satisfied. + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "__uzma", + Value: "fake-clearance", + Path: "/", + }) + _, _ = w.Write([]byte(`signin`)) + }) + + // Step 1 auth endpoint. + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authResponse) + }) + + // Step 2 auth endpoint (TOTP). + mux.HandleFunc("/signin/auth2.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(auth2Response) + }) + + return mux +} + +// newBrowserClient creates a *Client wired with a browserBackend whose opts +// point at the given test server URL. The http.Client jar is already set up +// (by NewClientWithOptions) and will receive copied cookies after login. +// +// The overrideHost param wires the backend so it navigates to srv.URL rather +// than the live hoverHost constant. This is test-only; production always uses +// hoverHost. +func newBrowserClient(t *testing.T, opts BrowserOptions, overrideHost string, creds Credentials) *Client { + t.Helper() + c, err := NewClientWithOptions(creds, nil, ClientOptions{Browser: opts}) + if err != nil { + t.Fatalf("NewClientWithOptions: %v", err) + } + // Wire the override host for local testing. + bb := c.backend.(*browserBackend) + bb.overrideHost = overrideHost + return c +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_ChromeMissingDownloadDisabled +// -------------------------------------------------------------------------- + +// TestBrowserBackend_ChromeMissingDownloadDisabled verifies that when no +// system Chrome is present and Download is false, Login returns ErrChromeUnavailable +// with an actionable message — before any navigation. +func TestBrowserBackend_ChromeMissingDownloadDisabled(t *testing.T) { + opts := BrowserOptions{ + Download: false, + Headless: true, + ProfileDir: t.TempDir(), + Path: "/nonexistent/chrome/binary", + Timeout: 10 * time.Second, + } + creds := Credentials{Username: "alice", Password: "pw"} + c, err := NewClientWithOptions(creds, nil, ClientOptions{Browser: opts}) + if err != nil { + t.Fatalf("NewClientWithOptions: %v", err) + } + bb := c.backend.(*browserBackend) + bb.overrideHost = "http://127.0.0.1:9" // unreachable; error must be before navigate + + err = c.Login(context.Background()) + if err == nil { + t.Fatal("expected error when Chrome binary is missing") + } + if !errors.Is(err, ErrChromeUnavailable) { + t.Errorf("expected ErrChromeUnavailable, got: %v", err) + } + // Message must be actionable. + msg := err.Error() + if !strings.Contains(msg, "Chrome") && !strings.Contains(msg, "chrome") { + t.Errorf("error message not actionable (no Chrome mention): %q", msg) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_LoginLocalNoMFA +// -------------------------------------------------------------------------- + +// TestBrowserBackend_LoginLocalNoMFA drives a full browser login against a +// local fake Hover signin server (no real Hover connection). Verifies that: +// - loggedAt is set on the Client after successful login +// - clearance cookies are copied into c.http.Jar +func TestBrowserBackend_LoginLocalNoMFA(t *testing.T) { + opts := newBrowserTestOpts(t) + + auth := map[string]any{"succeeded": true, "status": "completed"} + mux := fakeSigninMux(auth, nil) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + + if err := c.Login(ctx); err != nil { + t.Fatalf("Login: %v", err) + } + + // loggedAt must be set. + c.mu.Lock() + loggedAt := c.loggedAt + c.mu.Unlock() + if loggedAt.IsZero() { + t.Fatal("loggedAt not set after successful login") + } + if time.Since(loggedAt) > 30*time.Second { + t.Errorf("loggedAt too stale: %v", loggedAt) + } + + // Clearance cookies must be in the jar. + srvURL, _ := url.Parse(srv.URL) + cookies := c.http.Jar.Cookies(srvURL) + found := false + for _, ck := range cookies { + if isClearanceCookie(ck.Name) { + found = true + break + } + } + if !found { + t.Errorf("clearance cookies not copied into c.http.Jar; jar cookies: %v", cookies) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_LoginLocalTOTPRequired +// -------------------------------------------------------------------------- + +// TestBrowserBackend_LoginLocalTOTPRequired verifies that when /signin/auth.json +// returns need_2fa and a TOTP secret is configured, the backend posts a generated +// code to /signin/auth2.json and succeeds. +func TestBrowserBackend_LoginLocalTOTPRequired(t *testing.T) { + opts := newBrowserTestOpts(t) + + // Build a fresh mux (don't use fakeSigninMux which already registers auth2). + var auth2Code string + mux := http.NewServeMux() + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fake-clearance", Path: "/"}) + _, _ = w.Write([]byte(`signin`)) + }) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": false, "status": "need_2fa"}) + }) + mux.HandleFunc("/signin/auth2.json", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if code, ok := body["code"].(string); ok { + auth2Code = code + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + totp := mustParse(t, rfc6238Secret) + creds := Credentials{Username: "alice", Password: "pw", TOTPSecret: totp} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + + if err := c.Login(ctx); err != nil { + t.Fatalf("Login with TOTP: %v", err) + } + + c.mu.Lock() + loggedAt := c.loggedAt + c.mu.Unlock() + if loggedAt.IsZero() { + t.Fatal("loggedAt not set after TOTP login") + } + + // auth2 code must be a 6-digit string. + if len(auth2Code) != 6 { + t.Errorf("TOTP code posted to auth2 = %q, want 6 digits", auth2Code) + } + for _, r := range auth2Code { + if r < '0' || r > '9' { + t.Errorf("non-digit in auth2 code: %q", auth2Code) + } + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_LoginEmail2FAWithoutTOTP +// -------------------------------------------------------------------------- + +// TestBrowserBackend_LoginEmail2FAWithoutTOTP verifies that when /signin/auth.json +// returns need_2fa but no TOTP secret is configured, Login returns ErrEmail2FARequired +// (not ErrBotChallenge, not a generic error). The message must be actionable. +func TestBrowserBackend_LoginEmail2FAWithoutTOTP(t *testing.T) { + opts := newBrowserTestOpts(t) + + auth := map[string]any{"succeeded": false, "status": "need_2fa"} + mux := fakeSigninMux(auth, nil) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // No TOTP secret configured. + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + + err := c.Login(ctx) + if err == nil { + t.Fatal("expected error when need_2fa without TOTP secret") + } + if !errors.Is(err, ErrEmail2FARequired) { + t.Errorf("expected ErrEmail2FARequired, got: %T %v", err, err) + } + // Must NOT be ErrBotChallenge. + if errors.Is(err, ErrBotChallenge) { + t.Errorf("got ErrBotChallenge; should be ErrEmail2FARequired") + } + // Message must mention TOTP/authenticator. + msg := err.Error() + if !strings.Contains(strings.ToLower(msg), "totp") && !strings.Contains(strings.ToLower(msg), "authenticator") { + t.Errorf("error message not actionable (no TOTP/authenticator mention): %q", msg) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_LoginDetectsBotChallenge +// -------------------------------------------------------------------------- + +// TestBrowserBackend_LoginDetectsBotChallenge verifies that when /signin/auth.json +// returns an HTTP 401/403 or an Imperva-style block, Login returns ErrBotChallenge. +func TestBrowserBackend_LoginDetectsBotChallenge(t *testing.T) { + opts := newBrowserTestOpts(t) + + mux := http.NewServeMux() + // Signin page — no clearance cookies set, simulating persistent Imperva block. + // We'll serve a page that blocks permanently. + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + // Imperva challenge page — no clearance cookies. + // The page content simulates a bot-challenge block page. + _, _ = w.Write([]byte(` +
+ + `)) + }) + // auth.json returns 401 (bot blocked before login even possible). + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"blocked"}`)) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + creds := Credentials{Username: "alice", Password: "pw"} + // Short timeout so the clearance-wait gives up quickly. + opts.Timeout = 8 * time.Second + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + err := c.Login(ctx) + if err == nil { + t.Fatal("expected error from bot-challenge server") + } + if !errors.Is(err, ErrBotChallenge) { + t.Errorf("expected ErrBotChallenge, got: %T %v", err, err) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_ReadsDelegateToHTTP +// -------------------------------------------------------------------------- + +// TestBrowserBackend_ReadsDelegateToHTTP verifies that after a successful +// browser login, ListDomains delegates to the HTTP path using the jar-backed +// http.Client — confirming the hybrid architecture handoff. +func TestBrowserBackend_ReadsDelegateToHTTP(t *testing.T) { + opts := newBrowserTestOpts(t) + + auth := map[string]any{"succeeded": true, "status": "completed"} + mux := fakeSigninMux(auth, nil) + + // Also serve /api/domains for the HTTP read delegation. + mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "succeeded": true, + "domains": []map[string]any{ + {"id": "dom1", "domain_name": "example.com"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + // Wire the HTTP client to point at the test server too. + c.http.Transport = rewriteTransport{base: srv.URL} + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + + // Login first so loggedAt is set. + if err := c.Login(ctx); err != nil { + t.Fatalf("Login: %v", err) + } + + // ListDomains should delegate to the HTTP backend (which hits /api/domains). + domains, err := c.ListDomains(ctx) + if err != nil { + t.Fatalf("ListDomains: %v", err) + } + if len(domains) != 1 || domains[0].Name != "example.com" { + t.Errorf("unexpected domains: %v", domains) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_WritesReturnUnavailable +// -------------------------------------------------------------------------- + +// TestBrowserBackend_WritesReturnUnavailable verifies that write operations +// still return ErrBrowserBackendUnavailable (Task 4 territory). +func TestBrowserBackend_WritesReturnUnavailable(t *testing.T) { + opts := BrowserOptions{ + Download: false, + Headless: true, + ProfileDir: t.TempDir(), + Timeout: 10 * time.Second, + } + bb := newBrowserBackend(opts) + + // Fake a logged-in client (loggedAt set, so ensureLogin won't fire). + c, err := NewClientWithOptions(Credentials{Username: "u", Password: "p"}, nil, ClientOptions{Browser: opts}) + if err != nil { + t.Fatalf("NewClientWithOptions: %v", err) + } + c.mu.Lock() + c.loggedAt = time.Now() + c.mu.Unlock() + _ = bb // ensure the backend is the right type + + ctx := context.Background() + if err := bb.SetNameservers(ctx, c, "example.com", []string{"ns1.com"}); !errors.Is(err, ErrBrowserBackendUnavailable) { + t.Errorf("SetNameservers: want ErrBrowserBackendUnavailable, got %v", err) + } + if _, err := bb.CreateRecord(ctx, c, "dom1", DNSRecord{}); !errors.Is(err, ErrBrowserBackendUnavailable) { + t.Errorf("CreateRecord: want ErrBrowserBackendUnavailable, got %v", err) + } + if err := bb.UpdateRecord(ctx, c, "r1", DNSRecord{}); !errors.Is(err, ErrBrowserBackendUnavailable) { + t.Errorf("UpdateRecord: want ErrBrowserBackendUnavailable, got %v", err) + } + if err := bb.DeleteRecord(ctx, c, "r1"); !errors.Is(err, ErrBrowserBackendUnavailable) { + t.Errorf("DeleteRecord: want ErrBrowserBackendUnavailable, got %v", err) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_CloseIsIdempotent +// -------------------------------------------------------------------------- + +// TestBrowserBackend_CloseIsIdempotent verifies Close() doesn't panic when +// called on a backend that was never used to launch a browser. +func TestBrowserBackend_CloseIsIdempotent(t *testing.T) { + opts := BrowserOptions{ + Download: false, + Headless: true, + ProfileDir: t.TempDir(), + Timeout: 5 * time.Second, + } + bb := newBrowserBackend(opts) + if err := bb.Close(); err != nil { + t.Errorf("Close on unused backend: %v", err) + } + if err := bb.Close(); err != nil { + t.Errorf("second Close on unused backend: %v", err) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_LoginSkipsWhenFresh +// -------------------------------------------------------------------------- + +// TestBrowserBackend_LoginSkipsWhenFresh verifies that a second Login call +// within sessionStaleAfter does not re-launch the browser. +func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) { + opts := newBrowserTestOpts(t) + + // Count how many times the signin page is hit. + signinHits := 0 + mux := http.NewServeMux() + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + signinHits++ + http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fake", Path: "/"}) + _, _ = w.Write([]byte(`signin`)) + }) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := c.Login(ctx); err != nil { + t.Fatalf("first Login: %v", err) + } + firstHits := signinHits + + // Second login: session is fresh — should not re-navigate. + if err := c.Login(ctx); err != nil { + t.Fatalf("second Login: %v", err) + } + + // Signin page must not have been hit again. + if signinHits != firstHits { + t.Errorf("browser re-launched on second Login; signin page hit count went from %d to %d", firstHits, signinHits) + } +} + +// TestBrowserBackend_OverrideHostRespected is a compile-time check that +// overrideHost field exists on browserBackend (used by all local tests above). +func TestBrowserBackend_OverrideHostRespected(_ *testing.T) { + bb := &browserBackend{} + _ = bb.overrideHost + _ = os.DevNull // suppress unused import warning +} From 1ef7e4669073129c48db8477ee9a2a1349608105 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 18:37:38 -0400 Subject: [PATCH 18/20] feat(hoverclient): execute hover dns writes in-browser (hybrid write path) Implement Task 4: CreateRecord, UpdateRecord, DeleteRecord, and SetNameservers on browserBackend now execute in-page via Chrome fetch (credentials:'include') so requests carry the live Imperva clearance and Chrome TLS fingerprint. Generalise browserSigninFetch into reusable browserFetchJSON/browserFetchWithHeaders helpers (probe + writes share one in-page fetch path). SetNameservers extracts CSRF from the control_panel page DOM in-browser then PUTs with X-CSRF-Token. Guards b.browser == nil with a clear "not initialised" error when Login was never run. All endpoints/payloads/typed-errors preserved from HTTP impls. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/hoverclient/browser_backend.go | 199 ++++++++- pkg/hoverclient/browser_backend_test.go | 44 +- pkg/hoverclient/browser_backend_write_test.go | 382 ++++++++++++++++++ pkg/hoverclient/browser_probe.go | 130 ++++-- 4 files changed, 703 insertions(+), 52 deletions(-) create mode 100644 pkg/hoverclient/browser_backend_write_test.go diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index edda99d..7ce0d56 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -2,6 +2,7 @@ package hoverclient import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -281,23 +282,203 @@ func (b *browserBackend) GetDomainDelegation(ctx context.Context, c *Client, dom } // --------------------------------------------------------------------------- -// WRITE operations — Task 4 (not yet implemented, return sentinel). +// WRITE operations — Task 4: in-browser DNS writes (hybrid write path). +// +// All four methods run the HTTP request in-page inside an authenticated Chrome +// page (credentials:'include'). This ensures the request carries Chrome's TLS +// fingerprint and the live Imperva clearance cookies — necessary for Hover's +// bot-protection layer to accept mutations. +// +// Pattern per method: +// 1. Ensure logged in (b.Login re-uses the fresh session when < sessionStaleAfter). +// 2. Open a fresh page on the live browser (pages are cheap; closing them is safe). +// 3. Navigate to a Hover page so the origin matches (required for same-origin +// credentials:'include' fetch to be accepted by the browser security model). +// 4. Execute the HTTP request via browserFetchWithHeaders / browserFetchJSON. +// 5. Parse the response on the Go side and return typed errors. // --------------------------------------------------------------------------- -func (b *browserBackend) CreateRecord(_ context.Context, _ *Client, _ string, _ DNSRecord) (*DNSRecord, error) { - return nil, ErrBrowserBackendUnavailable +// openWritePage opens a new browser page on the live browser, navigates it to +// base+"/api/dns" to establish the same-origin context required for +// credentials:'include' fetch calls. The caller is responsible for closing the +// page via the returned cleanup func. +// +// b.browser.Context(ctx) returns a temporary clone of the browser that uses ctx +// rather than the browser's internal (potentially-expired login-timeout) context, +// so new page creation honours the caller's deadline rather than the login one. +func (b *browserBackend) openWritePage(ctx context.Context, base string) (*rod.Page, func(), error) { + if b.browser == nil { + return nil, nil, fmt.Errorf("hover browser write: browser not initialised (Login must succeed before write operations)") + } + page, err := b.browser.Context(ctx).Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + return nil, nil, fmt.Errorf("hover browser write: new page: %w", err) + } + page = page.Context(ctx) + // Navigate to any Hover page so `fetch` calls in-page are treated as + // same-origin requests (credentials:'include' requires matching origins). + // We use /api/dns because it's a stable, lightweight JSON endpoint. + if err := page.Navigate(base + "/api/dns"); err != nil { + _ = page.Close() + return nil, nil, fmt.Errorf("hover browser write: navigate: %w", err) + } + _ = page.WaitLoad() + cleanup := func() { _ = page.Close() } + return page, cleanup, nil } -func (b *browserBackend) UpdateRecord(_ context.Context, _ *Client, _ string, _ DNSRecord) error { - return ErrBrowserBackendUnavailable +// CreateRecord adds a new DNS record for the domain. Executes POST /api/dns +// in-page with application/x-www-form-urlencoded payload, matching the HTTP +// backend exactly but running inside Chrome's TLS session. +func (b *browserBackend) CreateRecord(ctx context.Context, c *Client, domainID string, rec DNSRecord) (*DNSRecord, error) { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return nil, err + } + base := b.signinHost() + page, cleanup, err := b.openWritePage(ctx, base) + if err != nil { + return nil, err + } + defer cleanup() + + form := map[string]any{ + "domain_id": domainID, + "name": rec.Name, + "type": rec.Type, + "content": rec.Content, + } + if rec.TTL > 0 { + form["ttl"] = fmt.Sprintf("%d", rec.TTL) + } + + rawBody, code, err := browserFetchJSON(ctx, page, "POST", base+"/api/dns", + "application/x-www-form-urlencoded", form) + if err != nil { + return nil, fmt.Errorf("hover browser CreateRecord: fetch: %w", err) + } + if code >= 400 { + return nil, fmt.Errorf("hover browser CreateRecord: HTTP %d: %s", code, strings.TrimSpace(rawBody)) + } + var out struct { + DNSRecord DNSRecord `json:"dns_record"` + } + if err := json.Unmarshal([]byte(rawBody), &out); err != nil { + return nil, fmt.Errorf("hover browser CreateRecord: parse response: %w", err) + } + return &out.DNSRecord, nil } -func (b *browserBackend) DeleteRecord(_ context.Context, _ *Client, _ string) error { - return ErrBrowserBackendUnavailable +// UpdateRecord updates an existing DNS record. Executes PUT /api/dns/ +// in-page with application/x-www-form-urlencoded payload. +func (b *browserBackend) UpdateRecord(ctx context.Context, c *Client, recordID string, rec DNSRecord) error { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return err + } + base := b.signinHost() + page, cleanup, err := b.openWritePage(ctx, base) + if err != nil { + return err + } + defer cleanup() + + form := map[string]any{"content": rec.Content} + if rec.TTL > 0 { + form["ttl"] = fmt.Sprintf("%d", rec.TTL) + } + endpoint := fmt.Sprintf("%s/api/dns/%s", base, url.PathEscape(recordID)) + rawBody, code, err := browserFetchJSON(ctx, page, "PUT", endpoint, + "application/x-www-form-urlencoded", form) + if err != nil { + return fmt.Errorf("hover browser UpdateRecord %q: fetch: %w", recordID, err) + } + if code >= 400 { + return fmt.Errorf("hover browser UpdateRecord %q: HTTP %d: %s", recordID, code, strings.TrimSpace(rawBody)) + } + return nil } -func (b *browserBackend) SetNameservers(_ context.Context, _ *Client, _ string, _ []string) error { - return ErrBrowserBackendUnavailable +// DeleteRecord removes a DNS record by ID. Executes DELETE /api/dns/ +// in-page (no body). +func (b *browserBackend) DeleteRecord(ctx context.Context, c *Client, recordID string) error { + if err := b.ensureLoggedIn(ctx, c); err != nil { + return err + } + base := b.signinHost() + page, cleanup, err := b.openWritePage(ctx, base) + if err != nil { + return err + } + defer cleanup() + + endpoint := fmt.Sprintf("%s/api/dns/%s", base, url.PathEscape(recordID)) + rawBody, code, err := browserFetchJSON(ctx, page, "DELETE", endpoint, "", nil) + if err != nil { + return fmt.Errorf("hover browser DeleteRecord %q: fetch: %w", recordID, err) + } + if code >= 400 { + return fmt.Errorf("hover browser DeleteRecord %q: HTTP %d: %s", recordID, code, strings.TrimSpace(rawBody)) + } + return nil +} + +// SetNameservers updates the registrar-level nameservers for a domain. +// Rejects empty ns immediately (same invariant as the HTTP backend). +// CSRF token is extracted in-browser from the control_panel domain page, then +// a PUT is issued in-page with the CSRF token in X-CSRF-Token. +func (b *browserBackend) SetNameservers(ctx context.Context, c *Client, domainName string, ns []string) error { + if len(ns) == 0 { + return fmt.Errorf("hover: SetNameservers %q: %w", domainName, ErrEmptyNameservers) + } + if err := b.ensureLoggedIn(ctx, c); err != nil { + return err + } + if b.browser == nil { + return fmt.Errorf("hover browser SetNameservers: browser not initialised (Login must succeed before write operations)") + } + base := b.signinHost() + + // Open a page and navigate to the control_panel domain page to read the CSRF. + // Use browser.Context(ctx) to override the browser's stored (login-timeout) + // context so new page creation honours the caller's deadline. + page, err := b.browser.Context(ctx).Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + return fmt.Errorf("hover browser SetNameservers: new page: %w", err) + } + page = page.Context(ctx) + defer func() { _ = page.Close() }() + + cpURL := fmt.Sprintf("%s/control_panel/domain/%s", base, url.PathEscape(domainName)) + if err := page.Navigate(cpURL); err != nil { + return fmt.Errorf("hover browser SetNameservers: navigate control_panel: %w", err) + } + _ = page.WaitLoad() + + // Extract the CSRF meta tag via JavaScript DOM access. + obj, err := page.Context(ctx).Eval(`() => { + const m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute('content') : ''; + }`) + if err != nil { + return fmt.Errorf("hover browser SetNameservers: eval CSRF: %w", err) + } + csrf := obj.Value.String() + if csrf == "" { + return fmt.Errorf("hover browser SetNameservers: CSRF meta tag not found at %s", cpURL) + } + + // Build PUT endpoint + payload (same as HTTP backend). + putEndpoint := fmt.Sprintf("%s/api/control_panel/domains/domain-%s", base, url.PathEscape(domainName)) + payload := map[string]any{"field": "nameservers", "value": ns} + + rawBody, code, err := browserFetchWithHeaders(ctx, page, "PUT", putEndpoint, + "application/json", payload, map[string]string{"X-CSRF-Token": csrf}) + if err != nil { + return fmt.Errorf("hover browser SetNameservers %q: fetch: %w", domainName, err) + } + if code >= 400 { + return fmt.Errorf("hover browser SetNameservers %q: HTTP %d: %s", domainName, code, strings.TrimSpace(rawBody)) + } + return nil } // --------------------------------------------------------------------------- diff --git a/pkg/hoverclient/browser_backend_test.go b/pkg/hoverclient/browser_backend_test.go index 73c1c04..54fe512 100644 --- a/pkg/hoverclient/browser_backend_test.go +++ b/pkg/hoverclient/browser_backend_test.go @@ -393,9 +393,12 @@ func TestBrowserBackend_ReadsDelegateToHTTP(t *testing.T) { // TestBrowserBackend_WritesReturnUnavailable // -------------------------------------------------------------------------- -// TestBrowserBackend_WritesReturnUnavailable verifies that write operations -// still return ErrBrowserBackendUnavailable (Task 4 territory). -func TestBrowserBackend_WritesReturnUnavailable(t *testing.T) { +// TestBrowserBackend_WritesRequireBrowserSession verifies that write operations +// on a browserBackend whose browser has not been initialised (no successful +// Login that launched Chrome) return a clear "not initialised" error rather +// than panicking or returning a misleading result. This covers the case where +// callers fake loggedAt but never actually ran Login (unit-test-only pattern). +func TestBrowserBackend_WritesRequireBrowserSession(t *testing.T) { opts := BrowserOptions{ Download: false, Headless: true, @@ -412,21 +415,32 @@ func TestBrowserBackend_WritesReturnUnavailable(t *testing.T) { c.mu.Lock() c.loggedAt = time.Now() c.mu.Unlock() - _ = bb // ensure the backend is the right type ctx := context.Background() - if err := bb.SetNameservers(ctx, c, "example.com", []string{"ns1.com"}); !errors.Is(err, ErrBrowserBackendUnavailable) { - t.Errorf("SetNameservers: want ErrBrowserBackendUnavailable, got %v", err) - } - if _, err := bb.CreateRecord(ctx, c, "dom1", DNSRecord{}); !errors.Is(err, ErrBrowserBackendUnavailable) { - t.Errorf("CreateRecord: want ErrBrowserBackendUnavailable, got %v", err) - } - if err := bb.UpdateRecord(ctx, c, "r1", DNSRecord{}); !errors.Is(err, ErrBrowserBackendUnavailable) { - t.Errorf("UpdateRecord: want ErrBrowserBackendUnavailable, got %v", err) - } - if err := bb.DeleteRecord(ctx, c, "r1"); !errors.Is(err, ErrBrowserBackendUnavailable) { - t.Errorf("DeleteRecord: want ErrBrowserBackendUnavailable, got %v", err) + // Each write operation must return a non-nil error mentioning "not initialised" + // (not a panic, not a nil error, not ErrBrowserBackendUnavailable). + checkWriteErr := func(t *testing.T, label string, err error) { + t.Helper() + if err == nil { + t.Errorf("%s: want error for uninitialised browser, got nil", label) + return + } + if !strings.Contains(err.Error(), "not initialised") { + t.Errorf("%s: error = %q; want message containing 'not initialised'", label, err.Error()) + } } + + err = bb.SetNameservers(ctx, c, "example.com", []string{"ns1.com"}) + checkWriteErr(t, "SetNameservers", err) + + _, err = bb.CreateRecord(ctx, c, "dom1", DNSRecord{}) + checkWriteErr(t, "CreateRecord", err) + + err = bb.UpdateRecord(ctx, c, "r1", DNSRecord{}) + checkWriteErr(t, "UpdateRecord", err) + + err = bb.DeleteRecord(ctx, c, "r1") + checkWriteErr(t, "DeleteRecord", err) } // -------------------------------------------------------------------------- diff --git a/pkg/hoverclient/browser_backend_write_test.go b/pkg/hoverclient/browser_backend_write_test.go new file mode 100644 index 0000000..87ea762 --- /dev/null +++ b/pkg/hoverclient/browser_backend_write_test.go @@ -0,0 +1,382 @@ +package hoverclient + +// browser_backend_write_test.go — TDD tests for Task 4: browserBackend +// in-browser DNS write operations (hybrid write path). +// +// All tests drive real go-rod against a local httptest server (no real Hover +// connection). Chrome is required; tests are skipped via newBrowserTestOpts +// when no binary is found (CI-safe). +// +// Test names: TestBrowserBackend_CreateRecordInBrowser, +// TestBrowserBackend_UpdateRecordInBrowser, +// TestBrowserBackend_DeleteRecordInBrowser, +// TestBrowserBackend_SetNameserversInBrowser, +// TestBrowserBackend_SetNameserversRejectsEmpty. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// fakeWriteMux returns an http.ServeMux that handles: +// - /signin, /signin/auth.json (fake successful login + clearance cookie) +// - DNS write endpoints recording the last received request +// +// The recordedReqs map (method → recorded request info) is populated by the +// handlers. The control_panel page returns a fixed CSRF token. +func fakeWriteMux(t *testing.T) (*http.ServeMux, *writeRequestLog) { + t.Helper() + log := &writeRequestLog{} + mux := http.NewServeMux() + + // Signin page — set clearance cookie so waitForClearanceCookies passes. + mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fake-clearance", Path: "/"}) + _, _ = w.Write([]byte(`signin`)) + }) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) + }) + + // CreateRecord: POST /api/dns + mux.HandleFunc("/api/dns", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + log.record(r) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dns_record": map[string]any{ + "id": "newid123", + "type": "A", + "name": "sub", + "content": "5.5.5.5", + "ttl": 300, + }, + }) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + }) + + // UpdateRecord + DeleteRecord: PUT/DELETE /api/dns/ + mux.HandleFunc("/api/dns/", func(w http.ResponseWriter, r *http.Request) { + log.record(r) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + + // SetNameservers CSRF page: GET /control_panel/domain/ + mux.HandleFunc("/control_panel/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(``)) + }) + + // SetNameservers PUT: /api/control_panel/domains/ + mux.HandleFunc("/api/control_panel/", func(w http.ResponseWriter, r *http.Request) { + log.record(r) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + + return mux, log +} + +// writeRequestLog captures the last recorded HTTP request (method, path, body, +// headers) across all write endpoints. Thread-safe. +type writeRequestLog struct { + mu sync.Mutex + entries []capturedRequest +} + +type capturedRequest struct { + Method string + Path string + Body []byte + Header http.Header +} + +func (l *writeRequestLog) record(r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + l.mu.Lock() + defer l.mu.Unlock() + l.entries = append(l.entries, capturedRequest{ + Method: r.Method, + Path: r.URL.Path, + Body: body, + Header: r.Header.Clone(), + }) +} + +// last returns the last captured request, or zero if none. +func (l *writeRequestLog) last() (capturedRequest, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.entries) == 0 { + return capturedRequest{}, false + } + return l.entries[len(l.entries)-1], true +} + +// firstMatching returns the first captured request whose Method matches, or +// zero if none. Useful when multiple requests are captured (e.g. login + write). +func (l *writeRequestLog) firstMatching(method, pathSuffix string) (capturedRequest, bool) { + l.mu.Lock() + defer l.mu.Unlock() + for _, e := range l.entries { + if e.Method == method && strings.HasSuffix(e.Path, pathSuffix) { + return e, true + } + } + return capturedRequest{}, false +} + +// newWriteBrowserClient creates a logged-in browser client wired to srv. +// It performs a Login so the browser page is ready and loggedAt is set. +func newWriteBrowserClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + opts := newBrowserTestOpts(t) + creds := Credentials{Username: "alice", Password: "pw"} + c := newBrowserClient(t, opts, srv.URL, creds) + t.Cleanup(func() { _ = c.backend.(interface{ Close() error }).Close() }) + + // Wire the HTTP client transport so HTTP-path calls (reads) also reach srv. + c.http.Transport = rewriteTransport{base: srv.URL} + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + if err := c.Login(ctx); err != nil { + t.Fatalf("Login: %v", err) + } + return c +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_CreateRecordInBrowser +// -------------------------------------------------------------------------- + +// TestBrowserBackend_CreateRecordInBrowser verifies that CreateRecord on the +// browser backend sends a POST to /api/dns in-page (not via Go http.Client), +// with the correct form-encoded payload, and returns the parsed DNSRecord. +func TestBrowserBackend_CreateRecordInBrowser(t *testing.T) { + mux, log := fakeWriteMux(t) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := newWriteBrowserClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rec := DNSRecord{Type: "A", Name: "sub", Content: "5.5.5.5", TTL: 300} + created, err := c.CreateRecord(ctx, "dom1", rec) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if created.ID != "newid123" { + t.Errorf("created.ID = %q, want %q", created.ID, "newid123") + } + if created.Content != "5.5.5.5" { + t.Errorf("created.Content = %q, want %q", created.Content, "5.5.5.5") + } + + // Verify the in-page fetch reached the server. + req, ok := log.firstMatching(http.MethodPost, "/api/dns") + if !ok { + t.Fatal("POST /api/dns not observed by server") + } + + // Body must contain the form fields (application/x-www-form-urlencoded or + // a JSON-encoded object from in-page fetch — check both shapes). + bodyStr := string(req.Body) + if strings.Contains(req.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + vals, _ := url.ParseQuery(bodyStr) + if vals.Get("domain_id") != "dom1" { + t.Errorf("form domain_id = %q, want %q", vals.Get("domain_id"), "dom1") + } + if vals.Get("type") != "A" { + t.Errorf("form type = %q, want A", vals.Get("type")) + } + if vals.Get("name") != "sub" { + t.Errorf("form name = %q, want sub", vals.Get("name")) + } + if vals.Get("content") != "5.5.5.5" { + t.Errorf("form content = %q, want 5.5.5.5", vals.Get("content")) + } + } else { + // JSON body: allow both shapes for test robustness. + if !strings.Contains(bodyStr, "dom1") { + t.Errorf("body missing domain_id: %q", bodyStr) + } + if !strings.Contains(bodyStr, "5.5.5.5") { + t.Errorf("body missing content: %q", bodyStr) + } + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_UpdateRecordInBrowser +// -------------------------------------------------------------------------- + +// TestBrowserBackend_UpdateRecordInBrowser verifies that UpdateRecord on the +// browser backend sends a PUT to /api/dns/ in-page, with content +// (and optionally ttl) in the body. +func TestBrowserBackend_UpdateRecordInBrowser(t *testing.T) { + mux, log := fakeWriteMux(t) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := newWriteBrowserClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := c.UpdateRecord(ctx, "rec456", DNSRecord{Content: "9.9.9.9", TTL: 600}) + if err != nil { + t.Fatalf("UpdateRecord: %v", err) + } + + req, ok := log.firstMatching(http.MethodPut, "/api/dns/rec456") + if !ok { + t.Fatal("PUT /api/dns/rec456 not observed by server") + } + + bodyStr := string(req.Body) + if strings.Contains(req.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + vals, _ := url.ParseQuery(bodyStr) + if vals.Get("content") != "9.9.9.9" { + t.Errorf("form content = %q, want 9.9.9.9", vals.Get("content")) + } + } else { + if !strings.Contains(bodyStr, "9.9.9.9") { + t.Errorf("body missing content 9.9.9.9: %q", bodyStr) + } + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_DeleteRecordInBrowser +// -------------------------------------------------------------------------- + +// TestBrowserBackend_DeleteRecordInBrowser verifies that DeleteRecord on the +// browser backend sends a DELETE to /api/dns/ in-page. +func TestBrowserBackend_DeleteRecordInBrowser(t *testing.T) { + mux, log := fakeWriteMux(t) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := newWriteBrowserClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := c.DeleteRecord(ctx, "rec789") + if err != nil { + t.Fatalf("DeleteRecord: %v", err) + } + + req, ok := log.firstMatching(http.MethodDelete, "/api/dns/rec789") + if !ok { + t.Fatal("DELETE /api/dns/rec789 not observed by server") + } + if req.Method != http.MethodDelete { + t.Errorf("method = %q, want DELETE", req.Method) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_SetNameserversInBrowser +// -------------------------------------------------------------------------- + +// TestBrowserBackend_SetNameserversInBrowser verifies that SetNameservers on +// the browser backend: +// 1. Fetches the CSRF token in-browser from /control_panel/domain/. +// 2. PUTs to /api/control_panel/domains/domain- with the nameservers +// payload and X-CSRF-Token header. +func TestBrowserBackend_SetNameserversInBrowser(t *testing.T) { + mux, log := fakeWriteMux(t) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := newWriteBrowserClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ns := []string{"ns1.example.com", "ns2.example.com"} + err := c.SetNameservers(ctx, "example.com", ns) + if err != nil { + t.Fatalf("SetNameservers: %v", err) + } + + req, ok := log.firstMatching(http.MethodPut, "/api/control_panel/domains/domain-example.com") + if !ok { + t.Fatal("PUT /api/control_panel/domains/domain-example.com not observed by server") + } + + // Must carry X-CSRF-Token from the control_panel page. + csrfToken := req.Header.Get("X-CSRF-Token") + if csrfToken != "test-csrf-abc" { + t.Errorf("X-CSRF-Token = %q, want test-csrf-abc", csrfToken) + } + + // Payload must contain field=nameservers + value=[ns1, ns2]. + var payload map[string]any + if err := json.Unmarshal(req.Body, &payload); err != nil { + t.Fatalf("decode PUT body: %v (raw: %q)", err, req.Body) + } + if payload["field"] != "nameservers" { + t.Errorf("field = %v, want nameservers", payload["field"]) + } + valSlice, ok := payload["value"].([]any) + if !ok { + t.Fatalf("value not []any: %T %v", payload["value"], payload["value"]) + } + if len(valSlice) != 2 { + t.Fatalf("value len = %d, want 2", len(valSlice)) + } + if valSlice[0] != "ns1.example.com" || valSlice[1] != "ns2.example.com" { + t.Errorf("value = %v, want [ns1.example.com ns2.example.com]", valSlice) + } +} + +// -------------------------------------------------------------------------- +// TestBrowserBackend_SetNameserversRejectsEmpty +// -------------------------------------------------------------------------- + +// TestBrowserBackend_SetNameserversRejectsEmpty verifies that SetNameservers +// with an empty slice returns ErrEmptyNameservers before touching the network. +func TestBrowserBackend_SetNameserversRejectsEmpty(t *testing.T) { + // No Chrome needed — this must fail before any network call. + opts := BrowserOptions{ + Download: false, + Headless: true, + ProfileDir: t.TempDir(), + Timeout: 10 * time.Second, + } + bb := newBrowserBackend(opts) + // Mark as logged in so ensureLogin doesn't try to launch Chrome. + c, err := NewClientWithOptions(Credentials{Username: "u", Password: "p"}, nil, ClientOptions{Browser: opts}) + if err != nil { + t.Fatalf("NewClientWithOptions: %v", err) + } + c.mu.Lock() + c.loggedAt = time.Now() + c.mu.Unlock() + + ctx := context.Background() + err = bb.SetNameservers(ctx, c, "example.com", []string{}) + if err == nil { + t.Fatal("expected error for empty nameservers") + } + if !errors.Is(err, ErrEmptyNameservers) { + t.Errorf("expected ErrEmptyNameservers, got: %v", err) + } +} diff --git a/pkg/hoverclient/browser_probe.go b/pkg/hoverclient/browser_probe.go index 5ddfc3a..7d76241 100644 --- a/pkg/hoverclient/browser_probe.go +++ b/pkg/hoverclient/browser_probe.go @@ -231,42 +231,116 @@ func submitBrowserSignin(ctx context.Context, page *rod.Page, creds Credentials) } func browserSigninFetch(ctx context.Context, page *rod.Page, endpoint string, payload map[string]any) (browserSigninResult, error) { - obj, err := page.Context(ctx).Evaluate(&rod.EvalOptions{ + raw, code, err := browserFetchJSON(ctx, page, "POST", endpoint, "application/json;charset=UTF-8", payload) + if err != nil { + return browserSigninResult{}, fmt.Errorf("hover browser signin: fetch %s: %w", endpoint, err) + } + var parsed struct { + Succeeded bool `json:"succeeded"` + Status string `json:"status"` + Error string `json:"error"` + } + _ = json.Unmarshal([]byte(raw), &parsed) + ok := code >= 200 && code < 300 + return browserSigninResult{ + OK: ok, + HTTPCode: code, + Succeeded: parsed.Succeeded, + Status: parsed.Status, + Error: parsed.Error, + Raw: raw, + }, nil +} + +// browserFetchResult is the raw result of a browserFetchJSON call. +type browserFetchResult struct { + OK bool `json:"ok"` + Code int `json:"code"` + RawBody string `json:"rawBody"` +} + +// browserFetchJSON executes an in-page fetch inside page with the given method, +// endpoint (path or full URL), contentType, and payload. The payload is +// serialized to either JSON (when contentType contains "application/json") or +// application/x-www-form-urlencoded (when it contains "form-urlencoded") using +// the values passed as a map[string]any. The function returns the response body +// as a string, the HTTP status code, and any JavaScript-level error. +// +// credentials:'include' is always set so Chrome's session cookies accompany the +// request — this is the key property that makes the hybrid write path work. +// +// extraHeaders is a map of additional headers to set (e.g. X-CSRF-Token). +// Pass nil when no extra headers are needed. +func browserFetchJSON(ctx context.Context, page *rod.Page, method, endpoint, contentType string, payload any) (rawBody string, code int, err error) { + return browserFetchWithHeaders(ctx, page, method, endpoint, contentType, payload, nil) +} + +// browserFetchWithHeaders is like browserFetchJSON but also accepts an +// extraHeaders map (may be nil). Each entry is set as a request header. +func browserFetchWithHeaders(ctx context.Context, page *rod.Page, method, endpoint, contentType string, payload any, extraHeaders map[string]string) (rawBody string, code int, err error) { + // Serialize the payload to the wire body on the Go side so we don't have to + // pass a complex encoding function to JS. + var bodyStr string + if payload != nil { + switch { + case strings.Contains(contentType, "application/x-www-form-urlencoded"): + // Encode map[string]any as URL-encoded form string. + m, ok := payload.(map[string]any) + if !ok { + return "", 0, fmt.Errorf("browserFetchWithHeaders: form-urlencoded payload must be map[string]any") + } + vals := url.Values{} + for k, v := range m { + vals.Set(k, fmt.Sprintf("%v", v)) + } + bodyStr = vals.Encode() + default: + // JSON-encode the payload. + b, jerr := json.Marshal(payload) + if jerr != nil { + return "", 0, fmt.Errorf("browserFetchWithHeaders: marshal payload: %w", jerr) + } + bodyStr = string(b) + } + } + + // Encode extraHeaders as a JSON object string so JS can parse it. + extraJSON := "{}" + if len(extraHeaders) > 0 { + b, jerr := json.Marshal(extraHeaders) + if jerr != nil { + return "", 0, fmt.Errorf("browserFetchWithHeaders: marshal extraHeaders: %w", jerr) + } + extraJSON = string(b) + } + + obj, evalErr := page.Context(ctx).Evaluate(&rod.EvalOptions{ ByValue: true, AwaitPromise: true, - JS: `async (endpoint, payload) => { - const response = await fetch(endpoint, { - method: 'POST', - credentials: 'include', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json;charset=UTF-8', - 'X-Requested-With': 'XMLHttpRequest' - }, - body: JSON.stringify(payload) - }); - const raw = await response.text(); - let parsed = {}; - try { parsed = raw ? JSON.parse(raw) : {}; } catch (e) {} - return { - ok: response.ok, - httpCode: response.status, - succeeded: !!parsed.succeeded, - status: parsed.status || '', - error: parsed.error || '', - raw + JS: `async (method, endpoint, contentType, body, extraHeadersJSON) => { + const headers = { + 'Accept': 'application/json, text/plain, */*', + 'X-Requested-With': 'XMLHttpRequest' }; + if (contentType) headers['Content-Type'] = contentType; + const extra = JSON.parse(extraHeadersJSON); + Object.assign(headers, extra); + const opts = { method, credentials: 'include', headers }; + if (body) opts.body = body; + const resp = await fetch(endpoint, opts); + const rawBody = await resp.text(); + return { ok: resp.ok, code: resp.status, rawBody }; }`, - JSArgs: []any{endpoint, payload}, + JSArgs: []any{method, endpoint, contentType, bodyStr, extraJSON}, }) - if err != nil { - return browserSigninResult{}, fmt.Errorf("hover browser signin: fetch %s: %w", endpoint, err) + if evalErr != nil { + return "", 0, evalErr } - var result browserSigninResult + var result browserFetchResult if err := obj.Value.Unmarshal(&result); err != nil { - return browserSigninResult{}, fmt.Errorf("hover browser signin: decode %s result: %w", endpoint, err) + return "", 0, fmt.Errorf("browserFetchWithHeaders: decode result: %w", err) } - return result, nil + return result.RawBody, result.Code, nil } func probeGoHTTPReuse(ctx context.Context, browser *rod.Browser) (int, bool) { From 07267febcfb76023dba619e0c0cf149e1766dff7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 18:44:25 -0400 Subject: [PATCH 19/20] docs(hover): browser-auth README + v0.5.0 manifests + live plugin test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite README to document the real Chrome/go-rod auth architecture (Imperva ABP bypass, hybrid browser-login + HTTP-read + in-browser-write model, Chrome acquisition, BrowserOptions config keys + env aliases, TOTP/email-2FA requirements, browser profile dir, typed errors). Remove stale CSRF form-login description. Bump both plugin.json manifests from 0.0.0 to 0.5.0 (behavioral minor for the browser-auth backend). Update iacserver_live_test.go: gate on HOVER_LIVE_TEST=1, source browser opts via BrowserOptionsFromEnv + config keys from env, exercise the full provider Initialize → EnumerateAll → Import → Status path (typed gRPC server surface), skip cleanly when HOVER_LIVE_TEST is unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 192 +++++++++++++++++--------- cmd/workflow-plugin-hover/plugin.json | 2 +- internal/iacserver_live_test.go | 131 +++++++++++++++--- plugin.json | 2 +- 4 files changed, 241 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 7f9c389..5e2e3c8 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,45 @@ [![CI](https://github.com/GoCodeAlone/workflow-plugin-hover/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/workflow-plugin-hover/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -> 🧪 **Experimental** — Hover DNS provider for the GoCodeAlone/workflow IaC surface. -> Hover has no official API; this plugin mimics the browser auth flow used by -> [pjslauta/hover-dyn-dns](https://github.com/pjslauta/hover-dyn-dns). Watch out -> for UI changes on hover.com that may break CSRF token parsing. - -## Auth flow - -1. GET `/signin` → parse `` (CSRF token). -2. POST `/signin` with `username`, `password`, `_token`. -3. GET `/signin/totp` to probe for MFA: - - If the page contains a `_token`: account has MFA enabled → POST `/signin/totp` - with `code` (RFC 6238 TOTP) + `_token`. - - If no `_token`: MFA is disabled → skip the TOTP submission (the - GET probe itself still runs). -4. Session cookies are stored in-memory for subsequent `/api/dns*` calls. - -Re-auth fires whenever the in-memory session is older than 1 hour. +> **Experimental** — Hover DNS provider for the GoCodeAlone/workflow IaC surface. +> Hover has no official API. This plugin drives a real Chrome browser to +> authenticate, then reuses session cookies for read operations and runs writes +> in-browser. + +## Why Chrome? + +Hover's signin endpoint is protected by Imperva Advanced Bot Protection (ABP). +A cold Go `http.Client` receives a 401/block response because it cannot run +Imperva's JavaScript sensor to mint the clearance cookie. A real Chrome instance +runs the JS sensor, passes the Imperva challenge, and completes the full login +flow (including TOTP 2FA). The resulting session + clearance cookies are then +reused by a lightweight Go `http.Client` for all read operations; writes that +require an in-page flow run in the same browser instance. + +## Hybrid architecture + +1. **Browser login**: Chrome drives the Hover signin page, passes the Imperva + challenge, and completes TOTP 2FA when configured. +2. **HTTP reads**: Session + Imperva clearance cookies are transferred to a Go + `http.Client`; all read operations (`ListDomains`, `GetDomain`, `ListRecords`, + `GetDomainDelegation`) use the cookie-reuse path. +3. **In-browser writes**: Mutations (`SetNameservers`, `CreateRecord`, + `UpdateRecord`, `DeleteRecord`) run in the browser where the full Imperva + context is live. + +The session is considered stale after 1 hour; a fresh browser login fires +automatically on the next operation. + +## Chrome acquisition + +The plugin needs a Chrome binary. Resolution order: + +1. `browser_path` config key (or `HOVER_BROWSER_PATH` / `ROD_BROWSER_PATH` env + var) — absolute path to an existing Chrome/Chromium binary. +2. Auto-download: if no binary is found and `browser_download` is `true` (or + `HOVER_BROWSER_DOWNLOAD=true`), go-rod downloads a compatible Chromium build + to a local cache. **Default: `true`.** +3. If neither finds a binary, `ErrChromeUnavailable` is returned. ## Configuration @@ -31,7 +53,13 @@ modules: provider: hover username: ${HOVER_USERNAME} password: ${HOVER_PASSWORD} - totp_secret: ${HOVER_TOTP_SECRET} + totp_secret: ${HOVER_TOTP_SECRET} # omit if account has no MFA + + # Browser options (all optional; shown with their defaults) + # browser_path: "" # absolute path to Chrome binary + # browser_download: true # auto-download Chromium if not found + # browser_headless: true # run Chrome headlessly + # browser_profile_dir: "" # persistent profile dir (see below) - name: example-com type: infra.dns @@ -43,70 +71,110 @@ modules: - { type: CNAME, name: 'www', content: example.com., ttl: 900 } ``` -The `records` key is **required**. The plugin treats your declared -list as authoritative: on apply, records present upstream but -absent from `records` are deleted, records present in `records` -but absent upstream are created, and records that differ are -updated. To deliberately drop every record from a zone, set -`records: []` — that explicit empty list is the only way to ask -for a wipe. Omitting `records` entirely is rejected at Plan time -to avoid the "I forgot the key and lost my zone" failure mode. +### Browser config env vars + +| Config key | Env alias | Default | +|----------------------|--------------------------|--------------------------------------------| +| `browser_path` | `HOVER_BROWSER_PATH`, `ROD_BROWSER_PATH` | (none — auto-discover or download) | +| `browser_download` | `HOVER_BROWSER_DOWNLOAD` | `true` | +| `browser_headless` | `HOVER_BROWSER_HEADLESS` | `true` | +| `browser_profile_dir`| `HOVER_BROWSER_PROFILE_DIR` | `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile` | + +Explicit config keys take precedence over env vars; env vars take precedence +over built-in defaults. + +## 2FA / TOTP + +Supply `HOVER_TOTP_SECRET` (base32-encoded seed from the Hover "Set up +authenticator" QR-code page) when your account has 2FA enabled. The plugin +computes RFC 6238 (SHA-1, 30 s step, 6 digits) codes in-process. + +**Email / non-TOTP 2FA is not supported.** If Hover reports `need_2fa` and no +TOTP secret is configured, the plugin returns `ErrEmail2FARequired`. To resolve: +either switch the account to an authenticator-app 2FA method and supply the +base32 seed, or pre-trust a persistent browser profile (see below) so the +security checkpoint is skipped on subsequent runs. + +## Browser profile directory + +The profile dir stores Imperva clearance cookies, Hover session cookies, and +other browser state across runs. It is sensitive — treat it like a credential +file and keep it out of version control. + +Default location: + +``` +${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile +``` + +Override via `browser_profile_dir` or `HOVER_BROWSER_PROFILE_DIR`. A warm +profile skips re-authentication against Imperva on subsequent runs and allows +the plugin to work even after a TOTP secret is no longer available. ## Required secrets -| Name | Sensitive | Source | -|------|-----------|--------| -| `HOVER_USERNAME` | no | Hover account login | +| Name | Sensitive | Purpose | +|------|-----------|---------| +| `HOVER_USERNAME` | no | Hover account login (email) | | `HOVER_PASSWORD` | **yes** | Hover account password | -| `HOVER_TOTP_SECRET` | **yes** | Base32 seed from Hover 2FA setup (the QR-code page shows a "Secret Key" field; copy that) | +| `HOVER_TOTP_SECRET` | **yes** | Base32 TOTP seed (required when account has authenticator 2FA) | -`wfctl secrets setup --plugin workflow-plugin-hover` prompts for each; -sensitive fields are masked. +```sh +wfctl secrets setup --plugin workflow-plugin-hover +``` -## Importing existing state +Sensitive fields are masked during interactive prompts. + +## Typed errors + +| Error | Meaning | +|-------|---------| +| `ErrBotChallenge` | Imperva blocked the browser session. Check network egress rules or rotate the browser profile. | +| `ErrChromeUnavailable` | No Chrome binary found and `browser_download=false`. Install Chrome or set `HOVER_BROWSER_DOWNLOAD=true`. | +| `ErrEmail2FARequired` | Account uses email/non-TOTP 2FA. Switch to an authenticator app or pre-trust a persistent browser profile. | -The plugin supports read-only import for existing Hover domains: +### Bot-challenge notice + +Driving a real browser against Imperva-protected pages is a best-effort +approach. Imperva may update its JS sensor in ways that break the clearance +flow; this is a known gray area in Imperva's terms of service. The plugin +documents this honestly and makes no guarantees of perpetual bypass. + +## Importing existing state ```sh wfctl infra import --config infra.yaml --name example-com-dns --id example.com wfctl infra import --config infra.yaml --name example-com-delegation --id example.com ``` -Declare the target resource in config first so `wfctl` can resolve the Hover -provider and resource type. `infra.dns` imports the zone records returned by -Hover. `infra.dns_delegation` imports the current registrar nameservers. -Imported state is marked as adoption-shaped state so follow-up plans can -compare against live outputs without treating the imported record set as a -user-authored apply config. +Declare the target resource in config first so `wfctl` resolves the Hover +provider and resource type. `infra.dns` imports zone records; `infra.dns_delegation` +imports registrar nameservers. Imported state is adoption-shaped so follow-up +plans compare against live outputs. -## TOTP +## DNS record semantics -In-process RFC 6238 (SHA-1, 30s step, 6 digits). The seed is decoded -once at plugin start; codes are computed on each login. Tested -against [RFC 6238 Appendix B vectors](https://datatracker.ietf.org/doc/html/rfc6238#appendix-B). - -## Caveats - -- **UI brittleness**: Hover's signin page can change. The plugin - fails loud with `CSRF token not found at /signin` when the regex - no longer matches. -- **CAPTCHA**: Hover may serve a CAPTCHA challenge on suspicious - logins. The plugin doesn't solve CAPTCHAs; you'll need to log - in manually from the same IP to seed trust, OR use a static - egress IP for the plugin runner. -- **Rate limit**: Stick to small zones; Hover's account portal - isn't optimised for bulk DNS edits. +The `records` key is **required**. The plugin treats your declared list as +authoritative: records present upstream but absent from `records` are deleted; +records in `records` but absent upstream are created; records that differ are +updated. To drop every record from a zone, set `records: []`. Omitting `records` +entirely is rejected at Plan time to prevent accidental zone wipes. ## Limitations -- **No zone delete**: Hover exposes no API to drop a DNS zone. - Resource `Delete` is a no-op — the IaC state is cleared but - upstream records remain. Operators who want to drop the zone - must do so manually via Hover's UI. +- **No zone delete**: Hover exposes no API to drop a DNS zone. Resource `Delete` + is a no-op — IaC state is cleared but upstream records remain. Drop the zone + manually via Hover's UI if needed. +- **Rate limit**: Stick to small zones; Hover's account portal is not optimised + for bulk DNS edits. ## Development ```sh -GOWORK=off go build ./... -GOWORK=off go test ./... -race -count=1 +GOWORK=off GOTOOLCHAIN=auto go build ./... +GOWORK=off GOTOOLCHAIN=auto go test ./... -race -count=1 ``` + +Browser unit tests in `pkg/hoverclient` launch real Chrome locally and may take +~20 s. They skip automatically when no Chrome binary is available and +`HOVER_BROWSER_DOWNLOAD` is not set. diff --git a/cmd/workflow-plugin-hover/plugin.json b/cmd/workflow-plugin-hover/plugin.json index a77a46b..eef5033 100644 --- a/cmd/workflow-plugin-hover/plugin.json +++ b/cmd/workflow-plugin-hover/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-hover", - "version": "0.0.0", + "version": "0.5.0", "description": "Hover DNS provider (browser-style login + TOTP, no official SDK)", "author": "GoCodeAlone", "license": "MIT", diff --git a/internal/iacserver_live_test.go b/internal/iacserver_live_test.go index 939594a..e462c3e 100644 --- a/internal/iacserver_live_test.go +++ b/internal/iacserver_live_test.go @@ -1,17 +1,23 @@ //go:build live_dns -// Env-gated live integration coverage for EnumerateAll("infra.dns"). +// Env-gated live integration coverage for the HoverProvider IaC surface +// (Initialize, EnumerateAll, Import, Status) exercising the browser backend. // // Run with: // -// INFRA_DNS_ENUMERATE_LIVE=1 \ +// HOVER_LIVE_TEST=1 \ // HOVER_USERNAME=$USER \ // HOVER_PASSWORD=$PASS \ // GOWORK=off go test -tags live_dns \ -// -run TestHoverProvider_EnumerateAll_DNS_live ./internal/... +// -run TestHoverProvider_IaC_live ./internal/... // -// HOVER_TOTP_SECRET is optional; supply it when the test account has MFA -// enabled. Per docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 13). +// Optional env vars: +// - HOVER_TOTP_SECRET — base32 TOTP seed (required when account has +// authenticator 2FA) +// - HOVER_BROWSER_PATH / ROD_BROWSER_PATH — absolute path to Chrome binary +// - HOVER_BROWSER_DOWNLOAD — allow go-rod to download Chromium (default true) +// - HOVER_BROWSER_HEADLESS — run Chrome headlessly (default true) +// - HOVER_BROWSER_PROFILE_DIR — persistent browser profile directory package internal import ( @@ -20,19 +26,19 @@ import ( "testing" "github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) -// newLiveHoverProvider builds a HoverProvider whose `domains` field is -// wired to the production *hoverclient.Client. Credentials come from -// HOVER_USERNAME + HOVER_PASSWORD (+ optional HOVER_TOTP_SECRET); the -// helper aborts the test (t.Fatal) when required env is missing so the -// live-only run is loud rather than silent. +// newLiveHoverProvider builds a HoverProvider wired to the production +// *hoverclient.Client using browser opts from HOVER_BROWSER_* env vars +// (matching the production Initialize path). The helper aborts the test +// (t.Fatal) when required credentials are missing. func newLiveHoverProvider(t *testing.T) *HoverProvider { t.Helper() user := os.Getenv("HOVER_USERNAME") pass := os.Getenv("HOVER_PASSWORD") if user == "" || pass == "" { - t.Fatal("HOVER_USERNAME + HOVER_PASSWORD must be set for live EnumerateAll test") + t.Fatal("HOVER_USERNAME + HOVER_PASSWORD must be set for live test") } var totpSecret hoverclient.TOTPSecret if totpRaw := os.Getenv("HOVER_TOTP_SECRET"); totpRaw != "" { @@ -42,31 +48,77 @@ func newLiveHoverProvider(t *testing.T) *HoverProvider { } totpSecret = ts } + + browserOpts, err := hoverclient.BrowserOptionsFromEnv() + if err != nil { + t.Fatalf("browser opts from env: %v", err) + } + creds := hoverclient.Credentials{ Username: user, Password: pass, TOTPSecret: totpSecret, } - c, err := hoverclient.NewClient(creds, nil) + c, err := hoverclient.NewClientWithOptions(creds, nil, hoverclient.ClientOptions{Browser: browserOpts}) if err != nil { - t.Fatalf("hoverclient.NewClient: %v", err) + t.Fatalf("hoverclient.NewClientWithOptions: %v", err) } return &HoverProvider{client: c, domains: c} } -func TestHoverProvider_EnumerateAll_DNS_live(t *testing.T) { - if os.Getenv("INFRA_DNS_ENUMERATE_LIVE") != "1" { - t.Skip("set INFRA_DNS_ENUMERATE_LIVE=1 + HOVER_USERNAME + HOVER_PASSWORD to run") +// newLiveInitializedProvider builds a HoverProvider via the production +// provider.Initialize path (parseBrowserConfig + NewClientWithOptions), +// sourcing all config from env vars. +func newLiveInitializedProvider(t *testing.T) *HoverProvider { + t.Helper() + cfg := map[string]any{ + "username": os.Getenv("HOVER_USERNAME"), + "password": os.Getenv("HOVER_PASSWORD"), + } + if s := os.Getenv("HOVER_TOTP_SECRET"); s != "" { + cfg["totp_secret"] = s + } + if s := os.Getenv("HOVER_BROWSER_PATH"); s != "" { + cfg["browser_path"] = s + } + if s := os.Getenv("HOVER_BROWSER_PROFILE_DIR"); s != "" { + cfg["browser_profile_dir"] = s + } + if s := os.Getenv("HOVER_BROWSER_DOWNLOAD"); s != "" { + cfg["browser_download"] = s + } + if s := os.Getenv("HOVER_BROWSER_HEADLESS"); s != "" { + cfg["browser_headless"] = s + } + p := NewHoverProvider() + if err := p.Initialize(context.Background(), cfg); err != nil { + t.Fatalf("provider Initialize: %v", err) + } + return p +} + +// TestHoverProvider_IaC_live exercises the full provider IaC path: +// EnumerateAll → Import → Status, using the browser-auth backend. +// Requires HOVER_LIVE_TEST=1 + HOVER_USERNAME + HOVER_PASSWORD. +func TestHoverProvider_IaC_live(t *testing.T) { + if os.Getenv("HOVER_LIVE_TEST") != "1" { + t.Skip("set HOVER_LIVE_TEST=1 + HOVER_USERNAME + HOVER_PASSWORD to run") } - p := newLiveHoverProvider(t) - out, err := p.EnumerateAll(context.Background(), "infra.dns") + ctx := context.Background() + + // Build an initialized provider via the production Initialize path so that + // parseBrowserConfig + NewClientWithOptions are exercised end-to-end. + p := newLiveInitializedProvider(t) + + // EnumerateAll — list all domains in the account. + enumOut, err := p.EnumerateAll(ctx, "infra.dns") if err != nil { t.Fatalf("live EnumerateAll: %v", err) } - if len(out) == 0 { - t.Skip("account has zero domains; cannot validate") + if len(enumOut) == 0 { + t.Skip("account has zero domains; cannot validate Import/Status paths") } - for _, o := range out { + for _, o := range enumOut { if o.ProviderID == "" { t.Errorf("empty ProviderID for %+v", o.Outputs) } @@ -77,5 +129,40 @@ func TestHoverProvider_EnumerateAll_DNS_live(t *testing.T) { t.Errorf("missing zone output: %+v", o.Outputs) } } - t.Logf("enumerated %d hover domains", len(out)) + t.Logf("enumerated %d hover domains", len(enumOut)) + + // Import — adopt the first domain via the provider Import path. + firstDomain := enumOut[0].ProviderID + state, err := p.Import(ctx, firstDomain, "infra.dns") + if err != nil { + t.Fatalf("live Import %q: %v", firstDomain, err) + } + if state == nil { + t.Fatal("Import returned nil state") + } + if state.Provider != "hover" { + t.Errorf("state.Provider = %q, want hover", state.Provider) + } + if state.ProviderID != firstDomain { + t.Errorf("state.ProviderID = %q, want %q", state.ProviderID, firstDomain) + } + if state.AppliedConfigSource != "adoption" { + t.Errorf("state.AppliedConfigSource = %q, want adoption", state.AppliedConfigSource) + } + t.Logf("imported domain %q: %d outputs", firstDomain, len(state.Outputs)) + + // Status — exercise the typed gRPC IaC server Status path. + srv := &hoverIaCServer{provider: p} + statusResp, err := srv.Status(ctx, &pb.StatusRequest{ + Refs: []*pb.ResourceRef{ + {Name: firstDomain, Type: "infra.dns", ProviderId: firstDomain}, + }, + }) + if err != nil { + t.Fatalf("live Status %q: %v", firstDomain, err) + } + if len(statusResp.GetStatuses()) == 0 { + t.Error("Status returned no statuses") + } + t.Logf("status for %q: %d statuses returned", firstDomain, len(statusResp.GetStatuses())) } diff --git a/plugin.json b/plugin.json index a77a46b..eef5033 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-hover", - "version": "0.0.0", + "version": "0.5.0", "description": "Hover DNS provider (browser-style login + TOTP, no official SDK)", "author": "GoCodeAlone", "license": "MIT", From 6330454cf3b958bbcf2c53623a8b82faa0d3c2a6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 18:50:35 -0400 Subject: [PATCH 20/20] docs(hover): security review + harden bot-challenge classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6: security review (PASS — no Critical/High; one tracked UA-derivation resilience follow-up). Fixes Login mislabeling a cookie-read error as ErrBotChallenge (only a clearance timeout is a challenge now). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0-headless-browser-auth.security-review.md | 30 +++++++++++++++++++ pkg/hoverclient/browser_backend.go | 8 +++-- 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-05-30-headless-browser-auth.security-review.md diff --git a/docs/plans/2026-05-30-headless-browser-auth.security-review.md b/docs/plans/2026-05-30-headless-browser-auth.security-review.md new file mode 100644 index 0000000..ce166df --- /dev/null +++ b/docs/plans/2026-05-30-headless-browser-auth.security-review.md @@ -0,0 +1,30 @@ +# Security Review — Headless-browser Hover auth (v0.5.0) + +**Date:** 2026-06-01 +**Reviewer:** Claude (Opus 4.8), lead +**Scope:** diff `origin/main...feat/headless-browser-auth-2026-05-30T2030` (28 files, +3418/−118) +**Plan:** docs/plans/2026-05-30-headless-browser-auth.md (scope-lock verified PASS) + +## Review items (plan Task 6 Step 1) + +| Item | Verdict | Evidence | +|---|---|---| +| Secret leakage in logs/errors/tests | **CLEAN** | grep of `pkg/hoverclient` + `internal` for log/print of pass/totp/secret/cred → 0 hits (one match was a comment in `totp.go`). Login submits creds via in-page `fetch`; errors carry endpoint + HTTP code, never the body's secret fields. Live test logs `clearance_cookies` names + domain count only. CI run showed `HOVER_USERNAME: ***` (masked). | +| Browser profile path safety + gitignore | **CLEAN** | Default profile dir is `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/hover/browser-profile` — **outside the repo**. Local test profile `.hover-browser-profile/` is gitignored (`git check-ignore` confirms). Profile holds session/clearance cookies → treated as sensitive; `Close()` uses `launcher.Kill()` (not `Cleanup()`) to preserve it across calls but never commits it. | +| No third-party credential/CAPTCHA solver | **CLEAN** | grep for captcha/2captcha/scrapfly/zenrows/anticaptcha/solver → 0 (the "resolver" hits are DNS-NS lookups). Credentials are typed only into a locally-launched Chrome on the operator's own runner; never sent to any third party. | +| Chrome download/path trust boundary | **CLEAN (standard)** | Chrome is resolved from system PATH/`browser_path` first; else go-rod's launcher downloads + checksum-verifies a pinned Chromium to `~/.cache/rod`. Driver is our maintained fork `github.com/GoCodeAlone/rod` (ADR 0002) — `govulncheck`-clean + Dependabot-clean + CodeQL-green. `--no-sandbox` is OFF by default; only enabled via explicit `HOVER_BROWSER_NO_SANDBOX=true`. | +| No public contract break | **CLEAN** | `hoverclient.Client` exported methods + signatures unchanged; `NewClient(creds, httpClient)` preserved; the browser/HTTP split is behind the private `executionBackend` seam. gRPC/IaC provider surface unchanged. | +| Not mock-only for the Imperva claim | **CLEAN** | Imperva-pass + TOTP login + 30-domain read validated against **real production Hover** via the gocodealone-dns CI probe on the self-hosted runner (`go_http_reuse_viable=true`, run 26784365604). Unit tests drive **real go-rod** against local httptest servers (not mocked-away); they `t.Skip` when no Chrome is present (CI-safe). | + +## Findings + +- **(Minor, fixed)** `Login` mislabeled a cookie-read failure as `ErrBotChallenge`; now only a clearance timeout maps to `ErrBotChallenge`, other read errors surface as-is. +- **(Important — tracked follow-up, NOT blocking)** **UA/platform/version skew.** `defaultUserAgent` is a fixed macOS Chrome 131 string and `Sec-Ch-Ua-Platform`/`SetUserAgent.Platform` are hardcoded `macOS`, while the CI runner's Chrome is Linux (and may differ in version). The presented identity is internally self-consistent (UA + client-hints both say macOS) but skews vs the real `navigator.platform`/version. **This configuration passed production**, so it ships as-is; deriving the UA + platform + version from the actually-launched Chrome (and stripping `HeadlessChrome`) is a resilience improvement to make + re-validate via the prod-run probe before relying on it. Tracked for a follow-up release. + +## Abuse / ToS + +Deliberately passes Imperva bot-protection — Hover ToS gray area. Hover has no API alternative. Shipped best-effort with a README disclaimer; public MIT plugin, documented not hidden. Re-evaluation cadence recorded in the design (2026-05-30 re-check: no maintained Go-native alternative; SOTA stealth tools are Python/Node). + +## Verdict + +**PASS.** No Critical/High findings. One Important resilience follow-up (UA derivation) accepted-and-tracked because the current config is production-proven. Secret handling, trust boundary, contract stability, and real-Hover validation all clean. diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index 7ce0d56..fd2338d 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -159,12 +159,14 @@ func (b *browserBackend) Login(ctx context.Context, c *Client) error { return fmt.Errorf("hover browser login: wait signin load: %w", err) } - // Wait for Imperva clearance cookies. If they never arrive → bot challenge. + // Wait for Imperva clearance cookies. A timeout/cancel means clearance was + // never minted → treat as a bot challenge. A genuine cookie-read failure is + // a browser malfunction, not a challenge — surface it as-is. if _, err := waitForClearanceCookies(ctx, browser); err != nil { if isContextErr(err) { - return fmt.Errorf("%w: %v", ErrBotChallenge, err) + return fmt.Errorf("%w: clearance cookies not minted: %v", ErrBotChallenge, err) } - return fmt.Errorf("%w: %v", ErrBotChallenge, err) + return fmt.Errorf("hover browser login: read clearance cookies: %w", err) } // Submit credentials via in-page fetch. The probe helper handles need_2fa