Skip to content

Commit 6a09d2d

Browse files
authored
qprobe: refuse a URL as a URL, not as a CIDR block (#51)
`qprobe --i-own-this https://example.com` failed with refusing CIDR block "https://example.com" - qProbe probes one host at a time, not ranges. The slash check ran before anything else, so every URL was reported as a range sweep: an error describing a mistake the operator had not made, and one that gave no hint about the fix. It cost a real probe run on a tracked repo. URLs are still refused, because a target is one named host, but now for the right reason and with the host to use: refusing URL "https://example.com/health" - qProbe takes a host, not a URL. Try: example.com A slash is only reported as a CIDR block when it is actually a prefix length (10.0.0.0/24); a pasted path says so instead. A target carrying credentials is refused explicitly, and the suggestion names the host it would really have connected to - --i-own-this is an ownership attestation, so a target that reads as one host and resolves to another must not be accepted quietly. The suggestion is for the message only. Nothing is newly accepted: the refusals that are security controls (CIDR, ranges, wildcards, lists) still throw before any network I/O.
1 parent 0fcc2ce commit 6a09d2d

5 files changed

Lines changed: 156 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0.
66

77
## [Unreleased]
88

9+
### Fixed - qProbe: a URL target is refused as a URL, not as a CIDR block
10+
11+
`qprobe --i-own-this https://example.com` failed with
12+
`refusing CIDR block "https://example.com" — qProbe probes one host at a time,
13+
not ranges.` The slash check ran before anything else, so every URL was reported
14+
as a range sweep: an error describing a mistake the operator had not made, and
15+
one that gave no hint about the fix. SemVer: **patch** (message and
16+
classification only; nothing newly accepted, nothing newly refused).
17+
18+
URLs are still refused — a target is one named host — but now for the right
19+
reason, and the message names the host to use:
20+
21+
```
22+
refusing URL "https://example.com/health" — qProbe takes a host, not a URL. Try: example.com
23+
```
24+
25+
- A slash is only reported as a **CIDR block** when it is actually a prefix
26+
length (`10.0.0.0/24`). A pasted path (`example.com/blog`) says so instead.
27+
- A target carrying **credentials** (`mine.com@theirs.com`) is refused
28+
explicitly, and the suggestion names the host it would really have connected
29+
to. `--i-own-this` is an ownership attestation, so a target that reads as one
30+
host and resolves to another cannot be accepted quietly.
31+
- The refusals that are security controls are unchanged: CIDR blocks, IP ranges,
32+
wildcards and lists still throw before any network I/O.
33+
934
### Fixed - Sieve: an implementation that cannot be run is no longer reported as a failing implementation
1035

1136
Pointing `--impl` at a command that does not exist produced a **FAIL** report

‎packages/qprobe/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ before using it.
1212

1313
> **Authorization required.** qProbe sends nothing until you attest you are
1414
> authorized to test the target. Active probing of endpoints you do not own may be
15-
> unlawful. qProbe refuses CIDR blocks, IP ranges, wildcards, and target lists — it
15+
> unlawful. A target is one host or `host:port` (`api.example.com`), never a URL.
16+
> qProbe refuses CIDR blocks, IP ranges, wildcards, and target lists — it
1617
> probes **one host you operate** at a time, and it only ever performs a benign,
1718
> unauthenticated handshake. It reports the negotiated reality; it never modifies an
1819
> endpoint ("engine disposes").

‎packages/qprobe/src/args.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ AUTHORIZATION (required — one of)
9494
--i-own-this Attest you are authorized to probe the given endpoint(s).
9595
--owned-hosts <file> Ownership manifest (one host per line, # comments).
9696
97+
A target is one host or host:port, e.g. api.example.com — not a URL.
9798
Active probing of endpoints you do not own may be unlawful. qprobe refuses CIDR
9899
blocks, IP ranges, wildcards and target lists. See THREAT-MODEL.md.
99100

‎packages/qprobe/src/target.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ export class TargetError extends Error {
2222
/** IPv4 range in the last octet, e.g. `10.0.0.1-50`, or a full-range dash form. */
2323
const IP_RANGE = /^\d{1,3}(?:\.\d{1,3}){3}\s*-\s*\d{1,3}/;
2424

25+
/** A URI scheme prefix: `https://`, `ssh://`, `ldaps://`. */
26+
const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
27+
28+
/** A CIDR suffix proper: a slash followed by a prefix length and nothing else. */
29+
const CIDR_SUFFIX = /\/\d{1,3}$/;
30+
31+
/**
32+
* The host someone probably meant, for the error message only.
33+
*
34+
* Never used to accept the input: a target has to be named as one host, because
35+
* `--i-own-this` is an ownership attestation and `https://mine.com@theirs.com/`
36+
* resolves to `theirs.com`. Suggesting the answer while still refusing keeps the
37+
* attestation explicit and the fix one copy-paste away.
38+
*/
39+
function suggestHost(raw: string): string | null {
40+
try {
41+
const url = new URL(SCHEME.test(raw) ? raw : `https://${raw}`);
42+
const host = url.hostname.replace(/^\[|\]$/g, "");
43+
return host || null;
44+
} catch {
45+
return null;
46+
}
47+
}
48+
2549
/**
2650
* Parse and validate a single target. Throws {@link TargetError} for anything
2751
* that is not a single host / host:port — the refusal is a security control, not
@@ -34,10 +58,39 @@ export function parseTarget(input: string, defaultPort: number): Target {
3458
const raw = input.trim();
3559
if (raw === "") throw new TargetError("empty target");
3660

61+
// A URL is still refused — a target is one named host, not something with a
62+
// scheme, credentials and a path — but it must be refused for the right reason.
63+
// This check has to come before the slash check below: `https://example.com`
64+
// contains slashes, so it used to be reported as a CIDR block, which described
65+
// a mistake the operator had not made.
66+
if (SCHEME.test(raw)) {
67+
const host = suggestHost(raw);
68+
throw new TargetError(
69+
`refusing URL "${raw}" — qProbe takes a host, not a URL${host ? `. Try: ${host}` : "."}`,
70+
);
71+
}
72+
73+
// Credentials name one host to a reader and connect to another
74+
// (`https://mine.com@theirs.com`), which an ownership attestation cannot allow.
75+
if (raw.includes("@")) {
76+
const host = suggestHost(raw);
77+
throw new TargetError(
78+
`refusing target with credentials "${raw}" — name the host directly${host ? `. Try: ${host}` : "."}`,
79+
);
80+
}
81+
3782
// Range / sweep / list markers are refused BEFORE any parsing.
3883
if (raw.includes("/")) {
84+
// Distinguish an actual CIDR block from a path someone pasted: only a
85+
// trailing prefix length is a range. Both are refused; only one is a sweep.
86+
if (CIDR_SUFFIX.test(raw)) {
87+
throw new TargetError(
88+
`refusing CIDR block "${raw}" — qProbe probes one host at a time, not ranges.`,
89+
);
90+
}
91+
const host = suggestHost(raw);
3992
throw new TargetError(
40-
`refusing CIDR block "${raw}" — qProbe probes one host at a time, not ranges.`,
93+
`refusing target with a path "${raw}" — qProbe probes a host, not a URL path${host ? `. Try: ${host}` : "."}`,
4194
);
4295
}
4396
if (raw.includes("*")) {

‎packages/qprobe/test/target.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,77 @@ test("rejects invalid ports", () => {
2222
assert.throws(() => parseTarget("h:99999", 443), TargetError);
2323
assert.throws(() => parseTarget("h:notaport", 443), TargetError);
2424
});
25+
26+
/**
27+
* A probe run failed with `refusing CIDR block "https://leonacosta.com"`. The
28+
* slash check ran first, so every URL was reported as a range sweep — an error
29+
* describing a mistake the operator had not made, and one that gave no hint
30+
* about the fix. URLs are still refused (a target is one named host), but for
31+
* the right reason and with the host to use.
32+
*/
33+
test("refuses a URL as a URL, not as a CIDR block, and names the host to use", () => {
34+
assert.throws(
35+
() => parseTarget("https://leonacosta.com", 443),
36+
(err: Error) => {
37+
assert.ok(err instanceof TargetError);
38+
assert.match(err.message, /not a URL/);
39+
assert.doesNotMatch(err.message, /CIDR/);
40+
assert.match(err.message, /Try: leonacosta\.com/);
41+
return true;
42+
},
43+
);
44+
});
45+
46+
test("refuses a bare path as a path, while a real CIDR is still a CIDR", () => {
47+
assert.throws(
48+
() => parseTarget("example.com/blog", 443),
49+
(err: Error) => {
50+
assert.match(err.message, /path/);
51+
assert.match(err.message, /Try: example\.com/);
52+
return true;
53+
},
54+
);
55+
// The security control this sits next to must not have been weakened.
56+
assert.throws(
57+
() => parseTarget("10.0.0.0/24", 443),
58+
(err: Error) => {
59+
assert.match(err.message, /CIDR block/);
60+
return true;
61+
},
62+
);
63+
});
64+
65+
/**
66+
* `--i-own-this` is an ownership attestation, so a target that reads as one host
67+
* and connects to another cannot be accepted. The suggestion deliberately names
68+
* the host that WOULD have been probed, which is the useful thing to see.
69+
*/
70+
test("refuses credentials in a target and reveals the host they resolve to", () => {
71+
assert.throws(
72+
() => parseTarget("mine.com@theirs.com", 443),
73+
(err: Error) => {
74+
assert.match(err.message, /credentials/);
75+
assert.match(err.message, /Try: theirs\.com/);
76+
return true;
77+
},
78+
);
79+
});
80+
81+
test("every scheme is refused, not just https", () => {
82+
for (const target of [
83+
"ssh://git.example.com",
84+
"ldaps://dir.example.com:636",
85+
"HTTP://example.com",
86+
]) {
87+
assert.throws(() => parseTarget(target, 443), TargetError, target);
88+
}
89+
});
90+
91+
test("still accepts the plain forms the refusals sit around", () => {
92+
assert.deepEqual(parseTarget("example.com", 443), { host: "example.com", port: 443 });
93+
assert.deepEqual(parseTarget("api.example.com:8443", 443), {
94+
host: "api.example.com",
95+
port: 8443,
96+
});
97+
assert.deepEqual(parseTarget("[2001:db8::1]:443", 443), { host: "2001:db8::1", port: 443 });
98+
});

0 commit comments

Comments
 (0)