Skip to content

feat(seo): Bing Webmaster read-path + opt-in URL submitter (pnpm bing)#786

Closed
choraria wants to merge 2 commits into
mainfrom
lane/bing-webmaster-readpath
Closed

feat(seo): Bing Webmaster read-path + opt-in URL submitter (pnpm bing)#786
choraria wants to merge 2 commits into
mainfrom
lane/bing-webmaster-readpath

Conversation

@choraria

Copy link
Copy Markdown
Contributor

Why

Bing is the only search engine whose submission path we can automate at all. Google's Indexing API accepts JobPosting/BroadcastEvent only, and Search Console's "Request indexing" is UI-only — so every Google submission is a human clicking a button. Bing exposes SubmitUrlBatch at a 100/day, 800/month quota.

The founder provisioned a Bing Webmaster API key; this makes it turnkey.

What

pnpm bing [crawl|sites|feeds|quota|submit], mirroring the gsc-* readers.

Submission is a DRY RUN unless --confirm is passed — running the command to see what it would do cannot silently write to a third party's index.

The second opinion it buys

Bing independently corroborates internal/marketing/seo-indexation-diagnosis.md, and states more plainly what Google only let us infer:

latest 2026-07-23: crawled=18 inIndex=3 inLinks=0 4xx=1 5xx=0 robotsBlocked=1
9-day window: 111 pages crawled

Bing is crawling actively (111 pages in nine days) and has 3 URLs in its index, with InLinks = 0 every single day of the window. Google made us infer "no authority" from its only reported referrers being spam domains; Bing reports the inbound-link count as an integer. Two engines disagreeing would have falsified the diagnosis. They agree.

Also confirms the Google-side sitemap defect was Google-side: Bing fetched docs.webhook.co/sitemap.xml successfully on 07-21, all 150 URLs — the exact file Google's stuck record refused to fetch for ten days.

Two security properties, both mutation-proven RED first

  • The API key travels in the query string, so any logged or thrown URL puts a live credential in the operator's scrollback and in the session transcript. Everything printed goes through redact(). Making it a no-op fails 2 tests.
  • assertSiteAllowed compares the parsed hostname, not a substring. A startsWith/includes check admits https://webhook.co.evil.com/ — the incomplete-URL-substring-sanitization defect CodeQL flags at HIGH, and which it has already flagged once in this repo. Mutating it to .includes() fails 1 test.

parseDotNetDate slices strings rather than matching a pattern. The natural regex — a greedy digit run followed by an optional fixed-width offset — trips security/detect-unsafe-regex, and a warning that is permanently "fine, ignore it" is how a real one later gets ignored too.

It also ignores the /Date(…-0700)/ offset rather than applying it: the epoch is already UTC, so honouring the offset would shift every timestamp by seven hours.

Verification

  • 20 new tests, zero networkfetch is injected throughout.
  • scripts/**: 638 pass, 0 fail. Full lint chain and 33/33 typecheck green via the pre-commit hook.
  • Run live against the real API: crawl, sites, feeds, quota, the dry-run submit, and the allowlist refusal (not an allowed site (host evil.com)).
  • The API key is not in the repo — verified by grep across the tree. It lives only in the memory directory at 600.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TZdJCPPGQGJYj8CywaR9KU

Bing is the only search engine whose submission path we can automate at all.
Google's Indexing API takes JobPosting/BroadcastEvent only and Search Console's
"Request indexing" is UI-only, so every Google submission is a human clicking a
button. Bing exposes SubmitUrlBatch at 100/day, 800/month.

`pnpm bing` reports crawl + index state, `sites`, `feeds`, `quota`, and
`submit`. Submission is a DRY RUN unless --confirm is passed, so running the
command to see what it would do cannot silently write to a third party's index.

It also gives us a second, independent read on the indexation diagnosis, and it
agrees: Bing crawled 111 pages in nine days, has 3 URLs in its index, and
reports InLinks = 0 every single day. Google let us infer "no authority" from
its only referrers being spam domains; Bing states the inbound-link count as a
number. Two engines disagreeing would have falsified the diagnosis.

Two security properties, both mutation-proven RED before the fix:

- The API key travels in the QUERY STRING, so any logged or thrown URL is a
  credential in the operator's scrollback and in the session transcript.
  Everything printed goes through redact(); making it a no-op fails 2 tests.
- assertSiteAllowed compares the PARSED hostname, not a substring. A
  startsWith/includes check admits https://webhook.co.evil.com/ -- the
  incomplete-URL-substring-sanitization defect CodeQL flags at HIGH, and which
  it already flagged once in this repo. Mutating it to .includes() fails 1 test.

parseDotNetDate slices strings rather than matching a pattern: the natural
regex trips security/detect-unsafe-regex, and a warning that is permanently
"fine, ignore it" is how a real one later gets ignored too.

20 new tests, no network (fetch is injected). scripts/**: 638 pass, 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZdJCPPGQGJYj8CywaR9KU
gitleaks failed CI on the test's fixture key -- a realistic 32-char hex string
reads as a credential to the generic-api-key rule (entropy 4.0), even though
it is an obvious dummy.

Fixed by making the fixture self-describing rather than by adding a
gitleaks:allow comment. Suppressing the scanner in a file whose entire subject
is credential hygiene teaches exactly the wrong lesson to the next reader, and
an allow-comment is indistinguishable at a glance from one covering a real
secret.

redact() matches on the `apikey=` query parameter, not on the value's shape,
so the swap weakens nothing: mutating redact() to a no-op still fails 2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZdJCPPGQGJYj8CywaR9KU
@github-actions

Copy link
Copy Markdown

🔴 AI review — VERDICT: BLOCK

Blocking issues found (advisory — review before merging; add the skip-ai-review label to silence).

Full review

Security review

No blocking issues (injection/XSS, committed secrets, authz/SSRF, PII in logs). Key handling uses env/*_FILE only; redact() covers throw/log paths; site allowlist uses parsed hostname (https-only, apex/subdomain); outbound fetch targets fixed API_BASE; no shell/eval; submit is dry-run unless --confirm.

Non-blocking notes only: prefer redirect: "error" / referrerPolicy: "no-referrer" on fetches (scripts/bing-webmaster.mjs:178, :214); scrub the loaded key value in redact, not only apikey= (scripts/bing-webmaster.mjs:57).

QA / test review

Blocking

  1. scripts/bing-webmaster.mjs:233-242 / scripts/bing-webmaster.test.mjs:162-164--confirm is only asserted on parseArgs. The dry-run branch in main() that must not call submitUrlBatch is untested; a regression that always submits would still pass.
    Fix: Export a testable run(argv, { fetchImpl, env }) (or equivalent). Assert: submit without --confirm → no SubmitUrlbatch / neverFetch unused; with --confirm + allowlisted URLs → exactly one POST.

  2. scripts/bing-webmaster.mjs:193 / scripts/bing-webmaster.test.mjs:167-174 — Foreign URLs in the batch are covered with neverFetch; foreign siteUrl on the write path is not.
    Fix: Add
    submitUrlBatch(KEY, "https://evil.com/", ["https://webhook.co/"], { fetchImpl: neverFetch }) → rejects /not an allowed site/, network not contacted.

No skipped tests or lowered coverage thresholds. Existing coverage for redact, lookalike-host allowlist, empty batch, and POST body shape is solid.

VERDICT: BLOCK

@choraria

Copy link
Copy Markdown
Contributor Author

Superseded by #787.

gitleaks scans branch history, not just the final tree — so fixing the test fixture in a follow-up commit left the original hex string in 86831ff6 and the check kept failing on a commit that no longer described the code. #787 is the identical change rebuilt as a single clean commit off main, so the secret-shaped fixture never exists in any commit at all.

No content difference between the two.

@choraria choraria closed this Jul 24, 2026
@choraria
choraria deleted the lane/bing-webmaster-readpath branch July 24, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant