diff --git a/docs/changelog-20260714-netbox-importers-waf-useragent.md b/docs/changelog-20260714-netbox-importers-waf-useragent.md new file mode 100644 index 0000000..e7297c7 --- /dev/null +++ b/docs/changelog-20260714-netbox-importers-waf-useragent.md @@ -0,0 +1,54 @@ +# 2026-07-14 -- the two pynetbox importers could NEVER have written upstream (WAF User-Agent) + +A C2 (feed-the-carve-upstream) blocker, found while establishing C2's feasibility. Measured, not +inferred. + +## The gap + +The upstream apex `netbox.baldurkeep.com` sits behind a filter that **403s the default Python +User-Agent** (`references/platform-traps.md`). The same token works from curl -- it looks EXACTLY +like an auth failure and is not. **MEASURED 2026-07-14 from vcloud:** `curl` -> HTTP 200; +`urllib`/pynetbox -> HTTP 403. + +The stdlib tools (`prod-draft-dump.py`, `sandbox-seed.py`, `d115-office-carve.py`) already send an +accepted UA (`curl/8.5.0`). But the two **pynetbox-based** importers -- +`roles-aggregates-import.py` and `dc-dc-prefixes-import.py` -- built `pynetbox.api(url, token)` with +the **default** UA and never overrode it. So they would 403 against upstream: **they could only ever +have written to the sandbox (which has no WAF), never to the apex C2 must feed.** The gap was +invisible because every real run so far was against the sandbox. + +## The fix + +`get_nb()` in both importers now sets the accepted UA on the underlying requests session: + + nb.http_session.headers["User-Agent"] = "curl/8.5.0" + +Same value and rationale as the stdlib tools -- pynetbox exposes its `requests.Session` as +`.http_session`, so one line covers every call it makes. + +## Tests + +- `tests/dc-dc-prefixes-import/` -> **86**: the fake now has an `.http_session` (mirroring + pynetbox's real one), and the test asserts AT RUNTIME that `get_nb()` sets the UA to `curl/8.5.0` + -- not just that the string appears in source. +- `tests/roles-aggregates-import/` -> **20**: grep assertion that `get_nb` sets the WAF-safe UA. + +GAUNTLET ALL GREEN (58); repo-lint 0 fail. + +## What this does and does NOT unblock + +**Does:** removes the WAF 403 from the two pynetbox importers' upstream write path. + +**Does NOT** (still open for C2): +- `pynetbox` is NOT installed on vcloud (measured). The two importers must run on a host that HAS it + and can reach upstream -- `office1-netbox` has pynetbox 7.0.0. `d115-office-carve.py` is stdlib and + runs anywhere. +- The sandbox re-verify (hardened `sandbox-fidelity-check.py`) needs the operator-held sandbox token. +- The upstream write itself is a PRODUCTION IPAM mutation and stays operator-gated. + +## Revert + + git revert + +The single line in each `get_nb()` is the only functional change; the rest is tests. No NetBox was +written. diff --git a/netbox/dc-dc-prefixes-import.py b/netbox/dc-dc-prefixes-import.py index 2d5ec6a..5e97cdb 100644 --- a/netbox/dc-dc-prefixes-import.py +++ b/netbox/dc-dc-prefixes-import.py @@ -235,6 +235,14 @@ if not token: die("NETBOX_TOKEN environment variable not set") nb = pynetbox.api(url, token=token) + # WAF TRAP (references/platform-traps.md): upstream NetBox 403s the DEFAULT + # python-requests User-Agent -- it looks EXACTLY like an auth failure but is + # not (the same token works from curl). pynetbox is bitten too. The stdlib + # tools (prod-draft-dump/sandbox-seed/d115-office-carve) already send an + # accepted UA; these two pynetbox importers did NOT, so they could never have + # written to the upstream apex (only the sandbox, which has no WAF). Set it + # on the underlying requests session so EVERY call carries it. + nb.http_session.headers["User-Agent"] = "curl/8.5.0" try: _ = nb.status() except Exception as exc: # noqa: BLE001 diff --git a/netbox/roles-aggregates-import.py b/netbox/roles-aggregates-import.py index 9be4830..5abc442 100644 --- a/netbox/roles-aggregates-import.py +++ b/netbox/roles-aggregates-import.py @@ -82,6 +82,14 @@ if not token: die("NETBOX_TOKEN environment variable not set") nb = pynetbox.api(url, token=token) + # WAF TRAP (references/platform-traps.md): upstream NetBox 403s the DEFAULT + # python-requests User-Agent -- it looks EXACTLY like an auth failure but is + # not (the same token works from curl). pynetbox is bitten too. The stdlib + # tools (prod-draft-dump/sandbox-seed/d115-office-carve) already send an + # accepted UA; these two pynetbox importers did NOT, so they could never have + # written to the upstream apex (only the sandbox, which has no WAF). Set it + # on the underlying requests session so EVERY call carries it. + nb.http_session.headers["User-Agent"] = "curl/8.5.0" try: _ = nb.status() except Exception as exc: # noqa: BLE001 diff --git a/tests/dc-dc-prefixes-import/fake_pynetbox.py b/tests/dc-dc-prefixes-import/fake_pynetbox.py index 0fb0e27..7fa7bc0 100644 --- a/tests/dc-dc-prefixes-import/fake_pynetbox.py +++ b/tests/dc-dc-prefixes-import/fake_pynetbox.py @@ -82,6 +82,13 @@ self.vlan_groups = Collection() +class _HttpSession: + """Stands in for pynetbox.api().http_session (a requests.Session).""" + + def __init__(self): + self.headers = {} + + class FakeApi: """Stands in for pynetbox.api(url, token=...).""" @@ -90,6 +97,9 @@ self.token = token self.dcim = _Dcim() self.ipam = _Ipam() + # Mirrors pynetbox.api's real .http_session (a requests.Session): get_nb() + # sets the WAF-safe User-Agent on it. A .headers dict is enough to record it. + self.http_session = _HttpSession() def status(self): return {"netbox-version": "fake"} diff --git a/tests/dc-dc-prefixes-import/test_logic.py b/tests/dc-dc-prefixes-import/test_logic.py index 44e0b7a..8dc8a3c 100644 --- a/tests/dc-dc-prefixes-import/test_logic.py +++ b/tests/dc-dc-prefixes-import/test_logic.py @@ -442,6 +442,18 @@ check("DC2_V4_SUPERNET" in _src and "DC1_V4_SUPERNET" in _src and "RETIRED (D-119)" in _src, "D-119: BOTH retired supernet env vars are rejected by name, not ignored") +# WAF trap (references/platform-traps.md): get_nb() MUST set the accepted +# User-Agent on the pynetbox requests session, or every upstream call 403s and +# this importer could never write to the apex (only the WAF-less sandbox). +# Asserted at RUNTIME: get_nb() runs against the fake, which records the header. +_fake_ua = fake_pynetbox.FakeApi() +pynetbox.api = lambda url=None, token=None: _fake_ua +os.environ["NETBOX_URL"] = "https://netbox.baldurkeep.com" +os.environ["NETBOX_TOKEN"] = "x" +T.get_nb() +check(_fake_ua.http_session.headers.get("User-Agent") == "curl/8.5.0", + "WAF: get_nb sets the accepted User-Agent on the pynetbox session (runtime)") + if F == 0: print(f"ALL PASS ({P} checks)") sys.exit(0) diff --git a/tests/roles-aggregates-import/run-tests.sh b/tests/roles-aggregates-import/run-tests.sh index 313ed0c..1ee133a 100755 --- a/tests/roles-aggregates-import/run-tests.sh +++ b/tests/roles-aggregates-import/run-tests.sh @@ -74,6 +74,11 @@ grep -q 'a typo must not cost a half-populated apex' "$S" && ok \ || bad "the ULA preflight rationale is gone -- someone will move it back below the writes" +# WAF trap: get_nb must set the accepted User-Agent on the pynetbox session, or +# every upstream call 403s (looks like auth failure; references/platform-traps.md). +grep -q 'http_session.headers\["User-Agent"\] = "curl/8.5.0"' "$S" && ok \ + || bad "get_nb does not set the WAF-safe User-Agent -- upstream writes will 403" + # 3. the six-plane slugs must match lib-net.sh SPACES6 exactly. for slug in provider-public metal-admin metal-internal data-tenant replication; do grep -q "\"$slug\"" "$S" && ok || bad "six-plane role slug '$slug' is gone"