From 66c26bce37288105fd6544e11aed6147e5e090f4 Mon Sep 17 00:00:00 2001 From: nguyenm7 <13559011+nguyenm7@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:28:52 +0000 Subject: [PATCH] fix(skills-catalog): close two holes in the discovery address guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the guard from safe-discovery.md against 41 executed cases and found two addresses it allowed that it documents as refused. fec0::/10 site-local was allowed. The link-local rule matches fe80-febf (`(b[1] & 0xc0) === 0x80`); fec0-feff fell through every remaining rule and reached `return null`. Deprecated by RFC 3879, still carried on internal networks, so it now returns "site-local". The 2001:db8::/32 documentation rule was dead code. It tested `(b[2] & 0xfe) === 0x0d`, and `x & 0xfe` always clears the low bit, so an odd 0x0d is unsatisfiable for every one of the 256 possible byte values. The equivalent IPv4 documentation ranges were already refused, so this was an inconsistency the mask hid. Matched exactly now. Verified by extracting the guard from the markdown and executing it: 41/41, 36 refusals and 5 allows. The allows (google.com, wisdom-api.enterpret.com, 8.8.8.8, 2607:f8b0:4009:812::200e, 2600::1) are in the same pass so the refusal list is not just an over-blocking guard. Both new refusals and the obfuscated 2130706433 / 0x7f.0.0.1 loopback forms are now documented with their real output. Manifest regenerated: only safe-discovery.md's sizeBytes/sha256, its skill contentHash and generatedAt move. Positive control first — regenerating before this edit reproduced the committed catalog.json byte-for-byte. validate-catalog clean, package suite 20/20. Co-Authored-By: Claude Opus 5 (1M context) --- .../references/safe-discovery.md | 29 +++++++++++++++++-- .../skills-catalog/generated/catalog.json | 8 ++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md index 6421de3f018..5e57a5330e7 100644 --- a/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md +++ b/packages/skills-catalog/catalog/optional/software-development/author-connector-definition/references/safe-discovery.md @@ -27,7 +27,7 @@ addresses it validated, and follows no redirect. | Any scheme other than `https:` | Refused. It is an allowlist, so `http:`, `file:`, `ftp:`, `gopher:` and `data:` all land here without being enumerated. | | A URL with userinfo (`https://a:b@host/`) | Refused. Credentials never belong on an unauthenticated discovery fetch, and userinfo is the classic way to make a host look like something it is not. | | Any port other than 443 | Refused. A provider that genuinely publishes discovery on another port is a gap to record and escalate, not a default to widen. | -| A host that resolves to loopback, private, CGNAT, link-local, unique-local, multicast, benchmarking, documentation or reserved space | Refused, and the reason names the address. This is on resolved addresses, so a public DNS name with a `127.0.0.1` record is caught. | +| A host that resolves to loopback, private, CGNAT, link-local, site-local, unique-local, multicast, benchmarking, documentation or reserved space | Refused, and the reason names the address. This is on resolved addresses, so a public DNS name with a `127.0.0.1` record is caught. | | An IPv4-mapped (`::ffff:a9fe:a9fe`), NAT64 (`64:ff9b::`) or 6to4 (`2002::`) address wrapping a blocked IPv4 address | Refused. The embedded address is extracted and run through the IPv4 rules. | | A host with several `A`/`AAAA` records where **one** is blocked | Refused. One bad answer in a round-robin set refuses the whole host. | | Anything resolvable and allowed | Fetched with `--resolve` pinned to exactly the addresses that were validated, so a second DNS answer cannot move the request after the check. | @@ -196,11 +196,16 @@ const classifyV6 = (addr) => { return classifyV4(inner) ? `6to4 ${inner} (${classifyV4(inner)})` : null; } if (b[0] === 0x01 && b[1] === 0x00 && zero(2, 8)) return "discard-only"; - if (b[0] === 0x20 && b[1] === 0x01 && (b[2] & 0xfe) === 0x0d && b[3] === 0xb8) + // 2001:db8::/32 — documentation. Match b[2] exactly: `b[2] & 0xfe` clears + // the low bit and so can never equal the odd 0x0d. + if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8) return "documentation"; if (b[0] === 0x20 && b[1] === 0x01 && b[2] < 0x02) return "IETF protocol assignments"; if ((b[0] & 0xfe) === 0xfc) return "unique local"; if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return "link-local"; + // fec0::/10 — site-local. Deprecated by RFC 3879 but still carried on some + // internal networks, so it stays refused. + if (b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) return "site-local"; if (b[0] === 0xff) return "multicast"; return null; }; @@ -369,9 +374,29 @@ https://[2002:7f00:1::]/x REFUSE 2002: https://attacker:pw@wisdom-api.enterpret.com/x REFUSE URL carries userinfo https://wisdom-api.enterpret.com:8443/x REFUSE port is not 443: 8443 https://localtest.me/x REFUSE localtest.me resolves to ::1 (loopback) +https://[fec0::1]/x REFUSE fec0::1 resolves to fec0::1 (site-local) +https://[2001:db8::1]/x REFUSE 2001:db8::1 resolves to 2001:db8::1 (documentation) +https://[::]/x REFUSE :: resolves to :: (unspecified address) +https://[ff02::1]/x REFUSE ff02::1 resolves to ff02::1 (multicast) +https://198.18.0.1/x REFUSE 198.18.0.1 resolves to 198.18.0.1 (benchmarking) +https://255.255.255.255/x REFUSE 255.255.255.255 resolves to 255.255.255.255 (reserved / broadcast) +https://2130706433/x REFUSE 127.0.0.1 resolves to 127.0.0.1 (loopback) +https://0x7f.0.0.1/x REFUSE 127.0.0.1 resolves to 127.0.0.1 (loopback) not-a-url REFUSE not a URL: not-a-url ``` +The last two are worth their own line: `2130706433` and `0x7f.0.0.1` are +`127.0.0.1` written as a decimal integer and with a hex first octet. A string +check for `127.` misses both. The guard never does string checks — it parses +the URL with the WHATWG parser, which normalizes these to `127.0.0.1` before +classification. + +The addresses that must **not** be refused, checked in the same pass so the +list above is not just an over-blocking guard: `https://google.com/`, +`https://wisdom-api.enterpret.com/`, `https://8.8.8.8/`, +`https://[2607:f8b0:4009:812::200e]/` and `https://[2600::1]/` all print +`ALLOW`. + ### The proxy bypass is load-bearing, not decorative `--resolve` is ignored the moment curl uses a proxy: the hostname goes to the diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index 558e8124a23..4ab4934d478 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-09-24T21:18:46.408Z", + "generatedAt": "2026-09-24T21:26:37.092Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -1171,11 +1171,11 @@ { "path": "references/safe-discovery.md", "kind": "reference", - "sizeBytes": 19316, - "sha256": "1679b4cb69267c28d0d62debfd74a6d60e071f21a2a091ecd0d674de7e64d768" + "sizeBytes": 21230, + "sha256": "869e8d3f28feaf806439494031f5a9e8543a0141c12baad6ede0b840f21bf881" } ], - "contentHash": "sha256:ab3b48a79c69480660b333bb5cd2af75fc318242f966f47ebd23707cd0c6a4c2" + "contentHash": "sha256:87454c5aef46a8361b5fcf75a74b276fe7c043b2522a62d0293a9f2c48fcdc4c" }, { "id": "paperclipai:optional:software-development:connect-agent-tools",