host-consensus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/VERIFY.md +97 -0
- package/dist/cli.cjs +219 -0
- package/dist/cli.d.cts +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +192 -0
- package/dist/index.cjs +214 -0
- package/dist/index.d.cts +70 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +183 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tamer Kalla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# host-consensus
|
|
2
|
+
|
|
3
|
+
**`http://example.com\@evil.com` is `example.com` to JavaScript and `evil.com` to Python.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/tamerkalla/host-consensus/actions/workflows/release.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/host-consensus)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://www.npmjs.com/package/host-consensus)
|
|
9
|
+
|
|
10
|
+
A runtime-path library — you import it into request-validation code and ship it.
|
|
11
|
+
Zero runtime dependencies. TypeScript, ES2022, dual ESM/CJS, with a small CLI.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install host-consensus
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| | reads the host as |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `new URL()` — WHATWG, what every browser and Node run | **example.com** |
|
|
20
|
+
| `urllib.parse` — RFC 3986, what most of the non-browser world runs | **evil.com** |
|
|
21
|
+
|
|
22
|
+
Validate that URL in Node against an allowlist and it passes as `example.com`.
|
|
23
|
+
Hand the same *string* to a Python service, a Go proxy, or anything else built
|
|
24
|
+
on RFC 3986, and the request goes to `evil.com`.
|
|
25
|
+
|
|
26
|
+
Neither parser is broken. WHATWG URL says a backslash is a path separator in a
|
|
27
|
+
special scheme; RFC 3986 says it is an ordinary character, so the authority runs
|
|
28
|
+
to `@` and the userinfo is `example.com\`. Both are behaving exactly as
|
|
29
|
+
specified.
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { inspect, agrees, assertConsensus } from 'host-consensus';
|
|
35
|
+
|
|
36
|
+
agrees('https://example.com/a'); // true
|
|
37
|
+
agrees('http://example.com\\@evil.com'); // false
|
|
38
|
+
|
|
39
|
+
assertConsensus('http://example.com\\@evil.com');
|
|
40
|
+
// throws HostDisagreementError:
|
|
41
|
+
// "http://example.com\@evil.com" is read as different hosts
|
|
42
|
+
// (whatwg=example.com, rfc3986=evil.com)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`inspect` returns both readings so you can log what actually differed:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
inspect('http://example.com\\@evil.com');
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"input": "http://example.com\\@evil.com",
|
|
54
|
+
"verdict": "host-differs",
|
|
55
|
+
"host": null,
|
|
56
|
+
"readings": [
|
|
57
|
+
{ "convention": "whatwg", "host": "example.com", "port": "", "origin": "http://example.com" },
|
|
58
|
+
{ "convention": "rfc3986", "host": "evil.com", "port": "", "origin": "http://evil.com" }
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
From the command line:
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
$ npx host-consensus 'http://example.com\@evil.com'
|
|
67
|
+
http://example.com\@evil.com
|
|
68
|
+
whatwg example.com
|
|
69
|
+
rfc3986 evil.com
|
|
70
|
+
verdict host-differs
|
|
71
|
+
$ echo $?
|
|
72
|
+
1
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## The seven constructions
|
|
76
|
+
|
|
77
|
+
Over a deterministic enumeration of 312 URLs — 2 schemes x 26 authority
|
|
78
|
+
constructions x 6 path suffixes — **84 rows** are read as a different host by
|
|
79
|
+
the two conventions. Seven distinct authority constructions sit behind them, and
|
|
80
|
+
each is a different mechanism:
|
|
81
|
+
|
|
82
|
+
| authority | WHATWG reads | RFC 3986 reads | why |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| `example.com\@evil.com` | `example.com` | `evil.com` | backslash is a slash in a special scheme |
|
|
85
|
+
| `0x7f.1` | `127.0.0.1` | `0x7f.1` | hexadecimal IPv4 canonicalisation |
|
|
86
|
+
| `017700000001` | `127.0.0.1` | `017700000001` | octal IPv4 canonicalisation |
|
|
87
|
+
| `2130706433` | `127.0.0.1` | `2130706433` | decimal IPv4 canonicalisation |
|
|
88
|
+
| `①.com` | `1.com` | `①.com` | UTS-46 mapping |
|
|
89
|
+
| `exаmple.com` (Cyrillic а) | `xn--exmple-4nf.com` | `exаmple.com` | IDNA to ASCII |
|
|
90
|
+
| `example。com` (U+3002) | `example.com` | `example。com` | ideographic full stop separates labels |
|
|
91
|
+
|
|
92
|
+
The IPv4 rows run the other way from the backslash row: `http://2130706433` is
|
|
93
|
+
`127.0.0.1` to a private-range filter written against `new URL()`, and an opaque
|
|
94
|
+
hostname to anything RFC-derived. Whichever side does the blocking, the other
|
|
95
|
+
side disagrees about what it is blocking.
|
|
96
|
+
|
|
97
|
+
A further **48 rows** across four constructions are an *acceptance* difference —
|
|
98
|
+
one convention refuses the URL outright and the other returns a host:
|
|
99
|
+
`exa mple.com`, `example.com:65536`, `example.com:+80`, `example.com: 80`.
|
|
100
|
+
|
|
101
|
+
The remaining **180 rows** are reported as agreeing. That matters as much as the
|
|
102
|
+
rest: differences in spelling — IPv6 brackets, host case — are normalised away
|
|
103
|
+
and never reported, so ordinary URLs come back clean.
|
|
104
|
+
|
|
105
|
+
## What this does not do
|
|
106
|
+
|
|
107
|
+
It does not resolve DNS, classify addresses as private, or make requests. Those
|
|
108
|
+
are a different job, and [`request-filtering-agent`](https://www.npmjs.com/package/request-filtering-agent)
|
|
109
|
+
does them well — it blocks requests to private ranges at the agent level. Use it
|
|
110
|
+
for that; use this to check that the URL string you validated is the one your
|
|
111
|
+
next hop will read.
|
|
112
|
+
|
|
113
|
+
The two are complementary: a private-range filter answers "is this address
|
|
114
|
+
safe", and `host-consensus` answers "will the next parser agree this is even the
|
|
115
|
+
same address".
|
|
116
|
+
|
|
117
|
+
## API
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
function inspect(url: string): Consensus;
|
|
121
|
+
function agrees(url: string): boolean;
|
|
122
|
+
function assertConsensus(url: string): string; // the agreed host, or throws
|
|
123
|
+
class HostDisagreementError extends Error { readonly consensus: Consensus }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`verdict` is one of:
|
|
127
|
+
|
|
128
|
+
- `'agreed'` — every convention names the same host.
|
|
129
|
+
- `'host-differs'` — all parsed, and they name different hosts.
|
|
130
|
+
- `'acceptance-differs'` — at least one refused and at least one accepted.
|
|
131
|
+
|
|
132
|
+
`inspect` throws `TypeError` on a non-string and nothing else. `agrees` never
|
|
133
|
+
throws on a malformed URL: a predicate that throws gets read as `false` by the
|
|
134
|
+
next person to use it, and "this URL is dangerous" must not be reachable by
|
|
135
|
+
accident. `assertConsensus` also throws when both conventions refuse, because
|
|
136
|
+
there is no host to return.
|
|
137
|
+
|
|
138
|
+
`inspect` performs no I/O — no network, no filesystem, no clock — and is a pure
|
|
139
|
+
function of its argument.
|
|
140
|
+
|
|
141
|
+
## How the RFC 3986 reading is checked
|
|
142
|
+
|
|
143
|
+
It is not checked against anybody's reading of the specification. `src/corpus.ts`
|
|
144
|
+
records the host and port that **CPython 3.11.15's `urllib.parse.urlsplit`**
|
|
145
|
+
returned for all 312 URLs, and the test suite asserts this package matches real
|
|
146
|
+
CPython on every one of them. See [VERIFY.md](VERIFY.md) to reproduce the
|
|
147
|
+
headline claim from a clean install in under a minute.
|
|
148
|
+
|
|
149
|
+
## Also from this author
|
|
150
|
+
|
|
151
|
+
[`yaml-drift`](https://www.npmjs.com/package/yaml-drift) reports every value
|
|
152
|
+
whose meaning changed converting YAML to JSON; `host-consensus` reports hosts
|
|
153
|
+
that two URL conventions read differently. Same shape — a boundary that silently
|
|
154
|
+
changes meaning — at a different boundary.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
package/VERIFY.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Verify the headline claim
|
|
2
|
+
|
|
3
|
+
The claim on the front of this package is that one URL string names two
|
|
4
|
+
different hosts depending on which convention reads it, and that
|
|
5
|
+
`host-consensus` catches that.
|
|
6
|
+
|
|
7
|
+
Reproduce it from a clean directory. Nothing here needs a checkout of this
|
|
8
|
+
repository — it installs the published package from the registry.
|
|
9
|
+
|
|
10
|
+
## 1. Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
mkdir -p /tmp/host-consensus-verify && cd /tmp/host-consensus-verify
|
|
14
|
+
npm init -y >/dev/null
|
|
15
|
+
npm install host-consensus@latest
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 2. Run the check
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx host-consensus 'http://example.com\@evil.com'
|
|
22
|
+
echo "exit=$?"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Expected output, exactly:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
http://example.com\@evil.com
|
|
29
|
+
whatwg example.com
|
|
30
|
+
rfc3986 evil.com
|
|
31
|
+
verdict host-differs
|
|
32
|
+
exit=1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 3. Confirm an ordinary URL is not flagged
|
|
36
|
+
|
|
37
|
+
A tool that flags everything would pass step 2 and still be useless.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx host-consensus 'https://example.com/a'
|
|
41
|
+
echo "exit=$?"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Expected output, exactly:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
https://example.com/a
|
|
48
|
+
whatwg example.com
|
|
49
|
+
rfc3986 example.com
|
|
50
|
+
verdict agreed
|
|
51
|
+
exit=0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 4. Confirm the library agrees with the CLI, in both module formats
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
node --input-type=module -e "
|
|
58
|
+
import { agrees } from 'host-consensus';
|
|
59
|
+
console.log('esm', agrees('https://example.com/a'), agrees('http://example.com\\\\@evil.com'));
|
|
60
|
+
"
|
|
61
|
+
node --input-type=commonjs -e "
|
|
62
|
+
const { agrees } = require('host-consensus');
|
|
63
|
+
console.log('cjs', agrees('https://example.com/a'), agrees('http://example.com\\\\@evil.com'));
|
|
64
|
+
"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Expected output, exactly:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
esm true false
|
|
71
|
+
cjs true false
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 5. Check it against a second implementation, if you have Python
|
|
75
|
+
|
|
76
|
+
This is the same comparison the package's own test suite makes against
|
|
77
|
+
CPython 3.11.15, and it needs no extra packages.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python3 -c "
|
|
81
|
+
from urllib.parse import urlsplit
|
|
82
|
+
print('python sees:', urlsplit('http://example.com\\\\@evil.com').hostname)
|
|
83
|
+
"
|
|
84
|
+
node -e "
|
|
85
|
+
console.log('node sees: ', new URL('http://example.com\\\\@evil.com').hostname);
|
|
86
|
+
"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Expected output, exactly:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
python sees: evil.com
|
|
93
|
+
node sees: example.com
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If those two lines differ from each other, the package's reason for existing is
|
|
97
|
+
reproduced on your machine.
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/cli.ts
|
|
22
|
+
var cli_exports = {};
|
|
23
|
+
__export(cli_exports, {
|
|
24
|
+
main: () => main,
|
|
25
|
+
run: () => run
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(cli_exports);
|
|
28
|
+
|
|
29
|
+
// src/compare.ts
|
|
30
|
+
function normaliseHost(host) {
|
|
31
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
32
|
+
return unbracketed.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
function verdictOf(readings) {
|
|
35
|
+
const accepted = readings.filter((r) => r.host !== null);
|
|
36
|
+
if (accepted.length !== 0 && accepted.length !== readings.length) {
|
|
37
|
+
return "acceptance-differs";
|
|
38
|
+
}
|
|
39
|
+
const keys = new Set(
|
|
40
|
+
readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
|
|
41
|
+
);
|
|
42
|
+
return keys.size === 1 ? "agreed" : "host-differs";
|
|
43
|
+
}
|
|
44
|
+
function consensusOf(input, readings) {
|
|
45
|
+
const verdict = verdictOf(readings);
|
|
46
|
+
const host = verdict === "agreed" ? readings[0].host : null;
|
|
47
|
+
return { input, verdict, host, readings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/rfc3986.ts
|
|
51
|
+
var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
|
|
52
|
+
function stripUnsafe(url) {
|
|
53
|
+
return url.replace(/[\t\r\n]/g, "");
|
|
54
|
+
}
|
|
55
|
+
function trimC0(url) {
|
|
56
|
+
let start = 0;
|
|
57
|
+
let end = url.length;
|
|
58
|
+
while (start < end && url.charCodeAt(start) <= 32) start++;
|
|
59
|
+
while (end > start && url.charCodeAt(end - 1) <= 32) end--;
|
|
60
|
+
return url.slice(start, end);
|
|
61
|
+
}
|
|
62
|
+
function splitScheme(url) {
|
|
63
|
+
const i = url.indexOf(":");
|
|
64
|
+
if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
|
|
65
|
+
return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
|
|
66
|
+
}
|
|
67
|
+
return { scheme: "", rest: url };
|
|
68
|
+
}
|
|
69
|
+
function netlocOf(rest) {
|
|
70
|
+
if (!rest.startsWith("//")) return null;
|
|
71
|
+
const after = rest.slice(2);
|
|
72
|
+
let end = after.length;
|
|
73
|
+
for (const c of ["/", "?", "#"]) {
|
|
74
|
+
const i = after.indexOf(c);
|
|
75
|
+
if (i !== -1 && i < end) end = i;
|
|
76
|
+
}
|
|
77
|
+
return after.slice(0, end);
|
|
78
|
+
}
|
|
79
|
+
function hostInfo(netloc) {
|
|
80
|
+
const at = netloc.lastIndexOf("@");
|
|
81
|
+
const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
|
|
82
|
+
const open = hostinfo.indexOf("[");
|
|
83
|
+
if (open !== -1) {
|
|
84
|
+
const bracketed = hostinfo.slice(open + 1);
|
|
85
|
+
const close = bracketed.indexOf("]");
|
|
86
|
+
const host = close === -1 ? bracketed : bracketed.slice(0, close);
|
|
87
|
+
const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
|
|
88
|
+
const colon2 = afterBracket.indexOf(":");
|
|
89
|
+
return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
|
|
90
|
+
}
|
|
91
|
+
const colon = hostinfo.indexOf(":");
|
|
92
|
+
if (colon === -1) return { host: hostinfo, port: "" };
|
|
93
|
+
return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
|
|
94
|
+
}
|
|
95
|
+
function lowerHost(host) {
|
|
96
|
+
const pct = host.indexOf("%");
|
|
97
|
+
if (pct === -1) return host.toLowerCase();
|
|
98
|
+
return host.slice(0, pct).toLowerCase() + host.slice(pct);
|
|
99
|
+
}
|
|
100
|
+
function normalisePort(port) {
|
|
101
|
+
if (port === "") return "";
|
|
102
|
+
if (!/^[0-9]+$/.test(port)) return null;
|
|
103
|
+
const n = Number(port);
|
|
104
|
+
if (n < 0 || n > 65535) return null;
|
|
105
|
+
return String(n);
|
|
106
|
+
}
|
|
107
|
+
function readRfc3986(url) {
|
|
108
|
+
const cleaned = trimC0(stripUnsafe(url));
|
|
109
|
+
const { scheme, rest } = splitScheme(cleaned);
|
|
110
|
+
const netloc = netlocOf(rest);
|
|
111
|
+
if (netloc === null) {
|
|
112
|
+
return {
|
|
113
|
+
convention: "rfc3986",
|
|
114
|
+
host: null,
|
|
115
|
+
port: null,
|
|
116
|
+
origin: null,
|
|
117
|
+
rejected: "no authority component"
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const { host, port } = hostInfo(netloc);
|
|
121
|
+
if (host === "") {
|
|
122
|
+
return {
|
|
123
|
+
convention: "rfc3986",
|
|
124
|
+
host: null,
|
|
125
|
+
port: null,
|
|
126
|
+
origin: null,
|
|
127
|
+
rejected: "empty host"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const lowered = lowerHost(host);
|
|
131
|
+
const normalisedPort = normalisePort(port);
|
|
132
|
+
const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
|
|
133
|
+
return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/whatwg.ts
|
|
137
|
+
function readWhatwg(url) {
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = new URL(url);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return {
|
|
143
|
+
convention: "whatwg",
|
|
144
|
+
host: null,
|
|
145
|
+
port: null,
|
|
146
|
+
origin: null,
|
|
147
|
+
rejected: error instanceof Error ? error.message : String(error)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (parsed.hostname === "") {
|
|
151
|
+
return {
|
|
152
|
+
convention: "whatwg",
|
|
153
|
+
host: null,
|
|
154
|
+
port: null,
|
|
155
|
+
origin: null,
|
|
156
|
+
rejected: "empty host"
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const origin = parsed.origin === "null" ? null : parsed.origin;
|
|
160
|
+
return {
|
|
161
|
+
convention: "whatwg",
|
|
162
|
+
host: parsed.hostname,
|
|
163
|
+
port: parsed.port,
|
|
164
|
+
origin
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/index.ts
|
|
169
|
+
function requireString(url, fn) {
|
|
170
|
+
if (typeof url !== "string") {
|
|
171
|
+
throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function inspect(url) {
|
|
175
|
+
requireString(url, "inspect");
|
|
176
|
+
return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/cli.ts
|
|
180
|
+
var USAGE = `Usage: host-consensus <url>
|
|
181
|
+
|
|
182
|
+
Reads <url> by both URL-parsing conventions and prints the host each one names.
|
|
183
|
+
|
|
184
|
+
Exit codes:
|
|
185
|
+
0 every convention agrees on the host
|
|
186
|
+
1 the conventions disagree
|
|
187
|
+
2 usage error`;
|
|
188
|
+
function main(argv, out) {
|
|
189
|
+
const args = argv.filter((a) => a !== "");
|
|
190
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
191
|
+
out(USAGE);
|
|
192
|
+
return args.length === 0 ? 2 : 0;
|
|
193
|
+
}
|
|
194
|
+
if (args.length > 1) {
|
|
195
|
+
out(`host-consensus: expected one URL, received ${args.length}
|
|
196
|
+
|
|
197
|
+
${USAGE}`);
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
const result = inspect(args[0]);
|
|
201
|
+
const width = Math.max(...result.readings.map((r) => r.convention.length));
|
|
202
|
+
out(result.input);
|
|
203
|
+
for (const reading of result.readings) {
|
|
204
|
+
const host = reading.host === null ? `<rejected: ${reading.rejected}>` : reading.host;
|
|
205
|
+
const port = reading.port ? `:${reading.port}` : "";
|
|
206
|
+
out(` ${reading.convention.padEnd(width)} ${host}${port}`);
|
|
207
|
+
}
|
|
208
|
+
out(` ${"verdict".padEnd(width)} ${result.verdict}`);
|
|
209
|
+
return result.verdict === "agreed" ? 0 : 1;
|
|
210
|
+
}
|
|
211
|
+
function run() {
|
|
212
|
+
process.exitCode = main(process.argv.slice(2), (s) => console.log(s));
|
|
213
|
+
}
|
|
214
|
+
run();
|
|
215
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
216
|
+
0 && (module.exports = {
|
|
217
|
+
main,
|
|
218
|
+
run
|
|
219
|
+
});
|
package/dist/cli.d.cts
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/compare.ts
|
|
4
|
+
function normaliseHost(host) {
|
|
5
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6
|
+
return unbracketed.toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function verdictOf(readings) {
|
|
9
|
+
const accepted = readings.filter((r) => r.host !== null);
|
|
10
|
+
if (accepted.length !== 0 && accepted.length !== readings.length) {
|
|
11
|
+
return "acceptance-differs";
|
|
12
|
+
}
|
|
13
|
+
const keys = new Set(
|
|
14
|
+
readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
|
|
15
|
+
);
|
|
16
|
+
return keys.size === 1 ? "agreed" : "host-differs";
|
|
17
|
+
}
|
|
18
|
+
function consensusOf(input, readings) {
|
|
19
|
+
const verdict = verdictOf(readings);
|
|
20
|
+
const host = verdict === "agreed" ? readings[0].host : null;
|
|
21
|
+
return { input, verdict, host, readings };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/rfc3986.ts
|
|
25
|
+
var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
|
|
26
|
+
function stripUnsafe(url) {
|
|
27
|
+
return url.replace(/[\t\r\n]/g, "");
|
|
28
|
+
}
|
|
29
|
+
function trimC0(url) {
|
|
30
|
+
let start = 0;
|
|
31
|
+
let end = url.length;
|
|
32
|
+
while (start < end && url.charCodeAt(start) <= 32) start++;
|
|
33
|
+
while (end > start && url.charCodeAt(end - 1) <= 32) end--;
|
|
34
|
+
return url.slice(start, end);
|
|
35
|
+
}
|
|
36
|
+
function splitScheme(url) {
|
|
37
|
+
const i = url.indexOf(":");
|
|
38
|
+
if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
|
|
39
|
+
return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
|
|
40
|
+
}
|
|
41
|
+
return { scheme: "", rest: url };
|
|
42
|
+
}
|
|
43
|
+
function netlocOf(rest) {
|
|
44
|
+
if (!rest.startsWith("//")) return null;
|
|
45
|
+
const after = rest.slice(2);
|
|
46
|
+
let end = after.length;
|
|
47
|
+
for (const c of ["/", "?", "#"]) {
|
|
48
|
+
const i = after.indexOf(c);
|
|
49
|
+
if (i !== -1 && i < end) end = i;
|
|
50
|
+
}
|
|
51
|
+
return after.slice(0, end);
|
|
52
|
+
}
|
|
53
|
+
function hostInfo(netloc) {
|
|
54
|
+
const at = netloc.lastIndexOf("@");
|
|
55
|
+
const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
|
|
56
|
+
const open = hostinfo.indexOf("[");
|
|
57
|
+
if (open !== -1) {
|
|
58
|
+
const bracketed = hostinfo.slice(open + 1);
|
|
59
|
+
const close = bracketed.indexOf("]");
|
|
60
|
+
const host = close === -1 ? bracketed : bracketed.slice(0, close);
|
|
61
|
+
const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
|
|
62
|
+
const colon2 = afterBracket.indexOf(":");
|
|
63
|
+
return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
|
|
64
|
+
}
|
|
65
|
+
const colon = hostinfo.indexOf(":");
|
|
66
|
+
if (colon === -1) return { host: hostinfo, port: "" };
|
|
67
|
+
return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
|
|
68
|
+
}
|
|
69
|
+
function lowerHost(host) {
|
|
70
|
+
const pct = host.indexOf("%");
|
|
71
|
+
if (pct === -1) return host.toLowerCase();
|
|
72
|
+
return host.slice(0, pct).toLowerCase() + host.slice(pct);
|
|
73
|
+
}
|
|
74
|
+
function normalisePort(port) {
|
|
75
|
+
if (port === "") return "";
|
|
76
|
+
if (!/^[0-9]+$/.test(port)) return null;
|
|
77
|
+
const n = Number(port);
|
|
78
|
+
if (n < 0 || n > 65535) return null;
|
|
79
|
+
return String(n);
|
|
80
|
+
}
|
|
81
|
+
function readRfc3986(url) {
|
|
82
|
+
const cleaned = trimC0(stripUnsafe(url));
|
|
83
|
+
const { scheme, rest } = splitScheme(cleaned);
|
|
84
|
+
const netloc = netlocOf(rest);
|
|
85
|
+
if (netloc === null) {
|
|
86
|
+
return {
|
|
87
|
+
convention: "rfc3986",
|
|
88
|
+
host: null,
|
|
89
|
+
port: null,
|
|
90
|
+
origin: null,
|
|
91
|
+
rejected: "no authority component"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const { host, port } = hostInfo(netloc);
|
|
95
|
+
if (host === "") {
|
|
96
|
+
return {
|
|
97
|
+
convention: "rfc3986",
|
|
98
|
+
host: null,
|
|
99
|
+
port: null,
|
|
100
|
+
origin: null,
|
|
101
|
+
rejected: "empty host"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const lowered = lowerHost(host);
|
|
105
|
+
const normalisedPort = normalisePort(port);
|
|
106
|
+
const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
|
|
107
|
+
return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/whatwg.ts
|
|
111
|
+
function readWhatwg(url) {
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = new URL(url);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
convention: "whatwg",
|
|
118
|
+
host: null,
|
|
119
|
+
port: null,
|
|
120
|
+
origin: null,
|
|
121
|
+
rejected: error instanceof Error ? error.message : String(error)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (parsed.hostname === "") {
|
|
125
|
+
return {
|
|
126
|
+
convention: "whatwg",
|
|
127
|
+
host: null,
|
|
128
|
+
port: null,
|
|
129
|
+
origin: null,
|
|
130
|
+
rejected: "empty host"
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const origin = parsed.origin === "null" ? null : parsed.origin;
|
|
134
|
+
return {
|
|
135
|
+
convention: "whatwg",
|
|
136
|
+
host: parsed.hostname,
|
|
137
|
+
port: parsed.port,
|
|
138
|
+
origin
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/index.ts
|
|
143
|
+
function requireString(url, fn) {
|
|
144
|
+
if (typeof url !== "string") {
|
|
145
|
+
throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function inspect(url) {
|
|
149
|
+
requireString(url, "inspect");
|
|
150
|
+
return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/cli.ts
|
|
154
|
+
var USAGE = `Usage: host-consensus <url>
|
|
155
|
+
|
|
156
|
+
Reads <url> by both URL-parsing conventions and prints the host each one names.
|
|
157
|
+
|
|
158
|
+
Exit codes:
|
|
159
|
+
0 every convention agrees on the host
|
|
160
|
+
1 the conventions disagree
|
|
161
|
+
2 usage error`;
|
|
162
|
+
function main(argv, out) {
|
|
163
|
+
const args = argv.filter((a) => a !== "");
|
|
164
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
165
|
+
out(USAGE);
|
|
166
|
+
return args.length === 0 ? 2 : 0;
|
|
167
|
+
}
|
|
168
|
+
if (args.length > 1) {
|
|
169
|
+
out(`host-consensus: expected one URL, received ${args.length}
|
|
170
|
+
|
|
171
|
+
${USAGE}`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
const result = inspect(args[0]);
|
|
175
|
+
const width = Math.max(...result.readings.map((r) => r.convention.length));
|
|
176
|
+
out(result.input);
|
|
177
|
+
for (const reading of result.readings) {
|
|
178
|
+
const host = reading.host === null ? `<rejected: ${reading.rejected}>` : reading.host;
|
|
179
|
+
const port = reading.port ? `:${reading.port}` : "";
|
|
180
|
+
out(` ${reading.convention.padEnd(width)} ${host}${port}`);
|
|
181
|
+
}
|
|
182
|
+
out(` ${"verdict".padEnd(width)} ${result.verdict}`);
|
|
183
|
+
return result.verdict === "agreed" ? 0 : 1;
|
|
184
|
+
}
|
|
185
|
+
function run() {
|
|
186
|
+
process.exitCode = main(process.argv.slice(2), (s) => console.log(s));
|
|
187
|
+
}
|
|
188
|
+
run();
|
|
189
|
+
export {
|
|
190
|
+
main,
|
|
191
|
+
run
|
|
192
|
+
};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
HostDisagreementError: () => HostDisagreementError,
|
|
24
|
+
agrees: () => agrees,
|
|
25
|
+
assertConsensus: () => assertConsensus,
|
|
26
|
+
inspect: () => inspect,
|
|
27
|
+
normaliseHost: () => normaliseHost
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/compare.ts
|
|
32
|
+
function normaliseHost(host) {
|
|
33
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
34
|
+
return unbracketed.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
function verdictOf(readings) {
|
|
37
|
+
const accepted = readings.filter((r) => r.host !== null);
|
|
38
|
+
if (accepted.length !== 0 && accepted.length !== readings.length) {
|
|
39
|
+
return "acceptance-differs";
|
|
40
|
+
}
|
|
41
|
+
const keys = new Set(
|
|
42
|
+
readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
|
|
43
|
+
);
|
|
44
|
+
return keys.size === 1 ? "agreed" : "host-differs";
|
|
45
|
+
}
|
|
46
|
+
function consensusOf(input, readings) {
|
|
47
|
+
const verdict = verdictOf(readings);
|
|
48
|
+
const host = verdict === "agreed" ? readings[0].host : null;
|
|
49
|
+
return { input, verdict, host, readings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/rfc3986.ts
|
|
53
|
+
var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
|
|
54
|
+
function stripUnsafe(url) {
|
|
55
|
+
return url.replace(/[\t\r\n]/g, "");
|
|
56
|
+
}
|
|
57
|
+
function trimC0(url) {
|
|
58
|
+
let start = 0;
|
|
59
|
+
let end = url.length;
|
|
60
|
+
while (start < end && url.charCodeAt(start) <= 32) start++;
|
|
61
|
+
while (end > start && url.charCodeAt(end - 1) <= 32) end--;
|
|
62
|
+
return url.slice(start, end);
|
|
63
|
+
}
|
|
64
|
+
function splitScheme(url) {
|
|
65
|
+
const i = url.indexOf(":");
|
|
66
|
+
if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
|
|
67
|
+
return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
|
|
68
|
+
}
|
|
69
|
+
return { scheme: "", rest: url };
|
|
70
|
+
}
|
|
71
|
+
function netlocOf(rest) {
|
|
72
|
+
if (!rest.startsWith("//")) return null;
|
|
73
|
+
const after = rest.slice(2);
|
|
74
|
+
let end = after.length;
|
|
75
|
+
for (const c of ["/", "?", "#"]) {
|
|
76
|
+
const i = after.indexOf(c);
|
|
77
|
+
if (i !== -1 && i < end) end = i;
|
|
78
|
+
}
|
|
79
|
+
return after.slice(0, end);
|
|
80
|
+
}
|
|
81
|
+
function hostInfo(netloc) {
|
|
82
|
+
const at = netloc.lastIndexOf("@");
|
|
83
|
+
const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
|
|
84
|
+
const open = hostinfo.indexOf("[");
|
|
85
|
+
if (open !== -1) {
|
|
86
|
+
const bracketed = hostinfo.slice(open + 1);
|
|
87
|
+
const close = bracketed.indexOf("]");
|
|
88
|
+
const host = close === -1 ? bracketed : bracketed.slice(0, close);
|
|
89
|
+
const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
|
|
90
|
+
const colon2 = afterBracket.indexOf(":");
|
|
91
|
+
return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
|
|
92
|
+
}
|
|
93
|
+
const colon = hostinfo.indexOf(":");
|
|
94
|
+
if (colon === -1) return { host: hostinfo, port: "" };
|
|
95
|
+
return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
|
|
96
|
+
}
|
|
97
|
+
function lowerHost(host) {
|
|
98
|
+
const pct = host.indexOf("%");
|
|
99
|
+
if (pct === -1) return host.toLowerCase();
|
|
100
|
+
return host.slice(0, pct).toLowerCase() + host.slice(pct);
|
|
101
|
+
}
|
|
102
|
+
function normalisePort(port) {
|
|
103
|
+
if (port === "") return "";
|
|
104
|
+
if (!/^[0-9]+$/.test(port)) return null;
|
|
105
|
+
const n = Number(port);
|
|
106
|
+
if (n < 0 || n > 65535) return null;
|
|
107
|
+
return String(n);
|
|
108
|
+
}
|
|
109
|
+
function readRfc3986(url) {
|
|
110
|
+
const cleaned = trimC0(stripUnsafe(url));
|
|
111
|
+
const { scheme, rest } = splitScheme(cleaned);
|
|
112
|
+
const netloc = netlocOf(rest);
|
|
113
|
+
if (netloc === null) {
|
|
114
|
+
return {
|
|
115
|
+
convention: "rfc3986",
|
|
116
|
+
host: null,
|
|
117
|
+
port: null,
|
|
118
|
+
origin: null,
|
|
119
|
+
rejected: "no authority component"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const { host, port } = hostInfo(netloc);
|
|
123
|
+
if (host === "") {
|
|
124
|
+
return {
|
|
125
|
+
convention: "rfc3986",
|
|
126
|
+
host: null,
|
|
127
|
+
port: null,
|
|
128
|
+
origin: null,
|
|
129
|
+
rejected: "empty host"
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const lowered = lowerHost(host);
|
|
133
|
+
const normalisedPort = normalisePort(port);
|
|
134
|
+
const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
|
|
135
|
+
return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/whatwg.ts
|
|
139
|
+
function readWhatwg(url) {
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = new URL(url);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
convention: "whatwg",
|
|
146
|
+
host: null,
|
|
147
|
+
port: null,
|
|
148
|
+
origin: null,
|
|
149
|
+
rejected: error instanceof Error ? error.message : String(error)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (parsed.hostname === "") {
|
|
153
|
+
return {
|
|
154
|
+
convention: "whatwg",
|
|
155
|
+
host: null,
|
|
156
|
+
port: null,
|
|
157
|
+
origin: null,
|
|
158
|
+
rejected: "empty host"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const origin = parsed.origin === "null" ? null : parsed.origin;
|
|
162
|
+
return {
|
|
163
|
+
convention: "whatwg",
|
|
164
|
+
host: parsed.hostname,
|
|
165
|
+
port: parsed.port,
|
|
166
|
+
origin
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/index.ts
|
|
171
|
+
function requireString(url, fn) {
|
|
172
|
+
if (typeof url !== "string") {
|
|
173
|
+
throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function inspect(url) {
|
|
177
|
+
requireString(url, "inspect");
|
|
178
|
+
return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
|
|
179
|
+
}
|
|
180
|
+
function agrees(url) {
|
|
181
|
+
requireString(url, "agrees");
|
|
182
|
+
return inspect(url).verdict === "agreed";
|
|
183
|
+
}
|
|
184
|
+
var HostDisagreementError = class _HostDisagreementError extends Error {
|
|
185
|
+
name = "HostDisagreementError";
|
|
186
|
+
consensus;
|
|
187
|
+
constructor(consensus) {
|
|
188
|
+
super(_HostDisagreementError.describe(consensus));
|
|
189
|
+
this.consensus = consensus;
|
|
190
|
+
}
|
|
191
|
+
static describe(consensus) {
|
|
192
|
+
const readings = consensus.readings.map((r) => `${r.convention}=${r.host === null ? `<rejected: ${r.rejected}>` : r.host}`).join(", ");
|
|
193
|
+
if (consensus.verdict === "agreed") {
|
|
194
|
+
return `${JSON.stringify(consensus.input)} names no host (${readings})`;
|
|
195
|
+
}
|
|
196
|
+
return `${JSON.stringify(consensus.input)} is read as different hosts (${readings})`;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
function assertConsensus(url) {
|
|
200
|
+
requireString(url, "assertConsensus");
|
|
201
|
+
const consensus = inspect(url);
|
|
202
|
+
if (consensus.verdict !== "agreed" || consensus.host === null) {
|
|
203
|
+
throw new HostDisagreementError(consensus);
|
|
204
|
+
}
|
|
205
|
+
return consensus.host;
|
|
206
|
+
}
|
|
207
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
208
|
+
0 && (module.exports = {
|
|
209
|
+
HostDisagreementError,
|
|
210
|
+
agrees,
|
|
211
|
+
assertConsensus,
|
|
212
|
+
inspect,
|
|
213
|
+
normaliseHost
|
|
214
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The two URL-parsing conventions in live use. */
|
|
2
|
+
type Convention = 'whatwg' | 'rfc3986';
|
|
3
|
+
interface Reading {
|
|
4
|
+
convention: Convention;
|
|
5
|
+
/** The host this convention says the URL names. `null` when it names none. */
|
|
6
|
+
host: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* The port as a string; `''` when the URL carries no port. `null` when this
|
|
9
|
+
* convention produced no usable port — either because it refused the URL, or
|
|
10
|
+
* because the port is present but not an integer in 0-65535.
|
|
11
|
+
*/
|
|
12
|
+
port: string | null;
|
|
13
|
+
/** `scheme://host[:port]`, or `null` where this convention defines none. */
|
|
14
|
+
origin: string | null;
|
|
15
|
+
/** Present only when `host` is `null`: why this convention produced no host. */
|
|
16
|
+
rejected?: string;
|
|
17
|
+
}
|
|
18
|
+
type Verdict =
|
|
19
|
+
/** Every convention names the same host. */
|
|
20
|
+
'agreed'
|
|
21
|
+
/** All conventions parsed, but they name different hosts. */
|
|
22
|
+
| 'host-differs'
|
|
23
|
+
/** At least one convention refused the URL and at least one accepted it. */
|
|
24
|
+
| 'acceptance-differs';
|
|
25
|
+
interface Consensus {
|
|
26
|
+
input: string;
|
|
27
|
+
verdict: Verdict;
|
|
28
|
+
/** The agreed host. `null` unless `verdict` is `'agreed'` with a host. */
|
|
29
|
+
host: string | null;
|
|
30
|
+
readings: Reading[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a host for comparison only. Surrounding IPv6 brackets are removed
|
|
35
|
+
* and ASCII case is folded; nothing else. A difference that survives this is a
|
|
36
|
+
* disagreement about which host is named. A difference that does not survive it
|
|
37
|
+
* is a difference in spelling, and reporting those would make the tool
|
|
38
|
+
* unusable — it would flag every IPv6 URL in the world.
|
|
39
|
+
*/
|
|
40
|
+
declare function normaliseHost(host: string): string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read `url` by both conventions and report whether they name the same host.
|
|
44
|
+
*
|
|
45
|
+
* Performs no I/O: no network, no filesystem, no clock. Pure in its argument.
|
|
46
|
+
*/
|
|
47
|
+
declare function inspect(url: string): Consensus;
|
|
48
|
+
/**
|
|
49
|
+
* `true` when every convention names the same host.
|
|
50
|
+
*
|
|
51
|
+
* Never throws on a malformed URL — a predicate that throws gets read as
|
|
52
|
+
* `false` by the next person to use it, and "this URL is dangerous" must not
|
|
53
|
+
* be reachable by accident.
|
|
54
|
+
*/
|
|
55
|
+
declare function agrees(url: string): boolean;
|
|
56
|
+
declare class HostDisagreementError extends Error {
|
|
57
|
+
readonly name: "HostDisagreementError";
|
|
58
|
+
readonly consensus: Consensus;
|
|
59
|
+
constructor(consensus: Consensus);
|
|
60
|
+
private static describe;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Return the host every convention agrees on, or throw `HostDisagreementError`.
|
|
64
|
+
*
|
|
65
|
+
* This is the guard: put it in front of anything that will hand the URL
|
|
66
|
+
* *string* to another system, which will parse it again by its own rules.
|
|
67
|
+
*/
|
|
68
|
+
declare function assertConsensus(url: string): string;
|
|
69
|
+
|
|
70
|
+
export { type Consensus, type Convention, HostDisagreementError, type Reading, type Verdict, agrees, assertConsensus, inspect, normaliseHost };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The two URL-parsing conventions in live use. */
|
|
2
|
+
type Convention = 'whatwg' | 'rfc3986';
|
|
3
|
+
interface Reading {
|
|
4
|
+
convention: Convention;
|
|
5
|
+
/** The host this convention says the URL names. `null` when it names none. */
|
|
6
|
+
host: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* The port as a string; `''` when the URL carries no port. `null` when this
|
|
9
|
+
* convention produced no usable port — either because it refused the URL, or
|
|
10
|
+
* because the port is present but not an integer in 0-65535.
|
|
11
|
+
*/
|
|
12
|
+
port: string | null;
|
|
13
|
+
/** `scheme://host[:port]`, or `null` where this convention defines none. */
|
|
14
|
+
origin: string | null;
|
|
15
|
+
/** Present only when `host` is `null`: why this convention produced no host. */
|
|
16
|
+
rejected?: string;
|
|
17
|
+
}
|
|
18
|
+
type Verdict =
|
|
19
|
+
/** Every convention names the same host. */
|
|
20
|
+
'agreed'
|
|
21
|
+
/** All conventions parsed, but they name different hosts. */
|
|
22
|
+
| 'host-differs'
|
|
23
|
+
/** At least one convention refused the URL and at least one accepted it. */
|
|
24
|
+
| 'acceptance-differs';
|
|
25
|
+
interface Consensus {
|
|
26
|
+
input: string;
|
|
27
|
+
verdict: Verdict;
|
|
28
|
+
/** The agreed host. `null` unless `verdict` is `'agreed'` with a host. */
|
|
29
|
+
host: string | null;
|
|
30
|
+
readings: Reading[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a host for comparison only. Surrounding IPv6 brackets are removed
|
|
35
|
+
* and ASCII case is folded; nothing else. A difference that survives this is a
|
|
36
|
+
* disagreement about which host is named. A difference that does not survive it
|
|
37
|
+
* is a difference in spelling, and reporting those would make the tool
|
|
38
|
+
* unusable — it would flag every IPv6 URL in the world.
|
|
39
|
+
*/
|
|
40
|
+
declare function normaliseHost(host: string): string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read `url` by both conventions and report whether they name the same host.
|
|
44
|
+
*
|
|
45
|
+
* Performs no I/O: no network, no filesystem, no clock. Pure in its argument.
|
|
46
|
+
*/
|
|
47
|
+
declare function inspect(url: string): Consensus;
|
|
48
|
+
/**
|
|
49
|
+
* `true` when every convention names the same host.
|
|
50
|
+
*
|
|
51
|
+
* Never throws on a malformed URL — a predicate that throws gets read as
|
|
52
|
+
* `false` by the next person to use it, and "this URL is dangerous" must not
|
|
53
|
+
* be reachable by accident.
|
|
54
|
+
*/
|
|
55
|
+
declare function agrees(url: string): boolean;
|
|
56
|
+
declare class HostDisagreementError extends Error {
|
|
57
|
+
readonly name: "HostDisagreementError";
|
|
58
|
+
readonly consensus: Consensus;
|
|
59
|
+
constructor(consensus: Consensus);
|
|
60
|
+
private static describe;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Return the host every convention agrees on, or throw `HostDisagreementError`.
|
|
64
|
+
*
|
|
65
|
+
* This is the guard: put it in front of anything that will hand the URL
|
|
66
|
+
* *string* to another system, which will parse it again by its own rules.
|
|
67
|
+
*/
|
|
68
|
+
declare function assertConsensus(url: string): string;
|
|
69
|
+
|
|
70
|
+
export { type Consensus, type Convention, HostDisagreementError, type Reading, type Verdict, agrees, assertConsensus, inspect, normaliseHost };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/compare.ts
|
|
2
|
+
function normaliseHost(host) {
|
|
3
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
4
|
+
return unbracketed.toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
function verdictOf(readings) {
|
|
7
|
+
const accepted = readings.filter((r) => r.host !== null);
|
|
8
|
+
if (accepted.length !== 0 && accepted.length !== readings.length) {
|
|
9
|
+
return "acceptance-differs";
|
|
10
|
+
}
|
|
11
|
+
const keys = new Set(
|
|
12
|
+
readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
|
|
13
|
+
);
|
|
14
|
+
return keys.size === 1 ? "agreed" : "host-differs";
|
|
15
|
+
}
|
|
16
|
+
function consensusOf(input, readings) {
|
|
17
|
+
const verdict = verdictOf(readings);
|
|
18
|
+
const host = verdict === "agreed" ? readings[0].host : null;
|
|
19
|
+
return { input, verdict, host, readings };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/rfc3986.ts
|
|
23
|
+
var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
|
|
24
|
+
function stripUnsafe(url) {
|
|
25
|
+
return url.replace(/[\t\r\n]/g, "");
|
|
26
|
+
}
|
|
27
|
+
function trimC0(url) {
|
|
28
|
+
let start = 0;
|
|
29
|
+
let end = url.length;
|
|
30
|
+
while (start < end && url.charCodeAt(start) <= 32) start++;
|
|
31
|
+
while (end > start && url.charCodeAt(end - 1) <= 32) end--;
|
|
32
|
+
return url.slice(start, end);
|
|
33
|
+
}
|
|
34
|
+
function splitScheme(url) {
|
|
35
|
+
const i = url.indexOf(":");
|
|
36
|
+
if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
|
|
37
|
+
return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
|
|
38
|
+
}
|
|
39
|
+
return { scheme: "", rest: url };
|
|
40
|
+
}
|
|
41
|
+
function netlocOf(rest) {
|
|
42
|
+
if (!rest.startsWith("//")) return null;
|
|
43
|
+
const after = rest.slice(2);
|
|
44
|
+
let end = after.length;
|
|
45
|
+
for (const c of ["/", "?", "#"]) {
|
|
46
|
+
const i = after.indexOf(c);
|
|
47
|
+
if (i !== -1 && i < end) end = i;
|
|
48
|
+
}
|
|
49
|
+
return after.slice(0, end);
|
|
50
|
+
}
|
|
51
|
+
function hostInfo(netloc) {
|
|
52
|
+
const at = netloc.lastIndexOf("@");
|
|
53
|
+
const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
|
|
54
|
+
const open = hostinfo.indexOf("[");
|
|
55
|
+
if (open !== -1) {
|
|
56
|
+
const bracketed = hostinfo.slice(open + 1);
|
|
57
|
+
const close = bracketed.indexOf("]");
|
|
58
|
+
const host = close === -1 ? bracketed : bracketed.slice(0, close);
|
|
59
|
+
const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
|
|
60
|
+
const colon2 = afterBracket.indexOf(":");
|
|
61
|
+
return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
|
|
62
|
+
}
|
|
63
|
+
const colon = hostinfo.indexOf(":");
|
|
64
|
+
if (colon === -1) return { host: hostinfo, port: "" };
|
|
65
|
+
return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
|
|
66
|
+
}
|
|
67
|
+
function lowerHost(host) {
|
|
68
|
+
const pct = host.indexOf("%");
|
|
69
|
+
if (pct === -1) return host.toLowerCase();
|
|
70
|
+
return host.slice(0, pct).toLowerCase() + host.slice(pct);
|
|
71
|
+
}
|
|
72
|
+
function normalisePort(port) {
|
|
73
|
+
if (port === "") return "";
|
|
74
|
+
if (!/^[0-9]+$/.test(port)) return null;
|
|
75
|
+
const n = Number(port);
|
|
76
|
+
if (n < 0 || n > 65535) return null;
|
|
77
|
+
return String(n);
|
|
78
|
+
}
|
|
79
|
+
function readRfc3986(url) {
|
|
80
|
+
const cleaned = trimC0(stripUnsafe(url));
|
|
81
|
+
const { scheme, rest } = splitScheme(cleaned);
|
|
82
|
+
const netloc = netlocOf(rest);
|
|
83
|
+
if (netloc === null) {
|
|
84
|
+
return {
|
|
85
|
+
convention: "rfc3986",
|
|
86
|
+
host: null,
|
|
87
|
+
port: null,
|
|
88
|
+
origin: null,
|
|
89
|
+
rejected: "no authority component"
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const { host, port } = hostInfo(netloc);
|
|
93
|
+
if (host === "") {
|
|
94
|
+
return {
|
|
95
|
+
convention: "rfc3986",
|
|
96
|
+
host: null,
|
|
97
|
+
port: null,
|
|
98
|
+
origin: null,
|
|
99
|
+
rejected: "empty host"
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const lowered = lowerHost(host);
|
|
103
|
+
const normalisedPort = normalisePort(port);
|
|
104
|
+
const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
|
|
105
|
+
return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/whatwg.ts
|
|
109
|
+
function readWhatwg(url) {
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = new URL(url);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
convention: "whatwg",
|
|
116
|
+
host: null,
|
|
117
|
+
port: null,
|
|
118
|
+
origin: null,
|
|
119
|
+
rejected: error instanceof Error ? error.message : String(error)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (parsed.hostname === "") {
|
|
123
|
+
return {
|
|
124
|
+
convention: "whatwg",
|
|
125
|
+
host: null,
|
|
126
|
+
port: null,
|
|
127
|
+
origin: null,
|
|
128
|
+
rejected: "empty host"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const origin = parsed.origin === "null" ? null : parsed.origin;
|
|
132
|
+
return {
|
|
133
|
+
convention: "whatwg",
|
|
134
|
+
host: parsed.hostname,
|
|
135
|
+
port: parsed.port,
|
|
136
|
+
origin
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/index.ts
|
|
141
|
+
function requireString(url, fn) {
|
|
142
|
+
if (typeof url !== "string") {
|
|
143
|
+
throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function inspect(url) {
|
|
147
|
+
requireString(url, "inspect");
|
|
148
|
+
return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
|
|
149
|
+
}
|
|
150
|
+
function agrees(url) {
|
|
151
|
+
requireString(url, "agrees");
|
|
152
|
+
return inspect(url).verdict === "agreed";
|
|
153
|
+
}
|
|
154
|
+
var HostDisagreementError = class _HostDisagreementError extends Error {
|
|
155
|
+
name = "HostDisagreementError";
|
|
156
|
+
consensus;
|
|
157
|
+
constructor(consensus) {
|
|
158
|
+
super(_HostDisagreementError.describe(consensus));
|
|
159
|
+
this.consensus = consensus;
|
|
160
|
+
}
|
|
161
|
+
static describe(consensus) {
|
|
162
|
+
const readings = consensus.readings.map((r) => `${r.convention}=${r.host === null ? `<rejected: ${r.rejected}>` : r.host}`).join(", ");
|
|
163
|
+
if (consensus.verdict === "agreed") {
|
|
164
|
+
return `${JSON.stringify(consensus.input)} names no host (${readings})`;
|
|
165
|
+
}
|
|
166
|
+
return `${JSON.stringify(consensus.input)} is read as different hosts (${readings})`;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
function assertConsensus(url) {
|
|
170
|
+
requireString(url, "assertConsensus");
|
|
171
|
+
const consensus = inspect(url);
|
|
172
|
+
if (consensus.verdict !== "agreed" || consensus.host === null) {
|
|
173
|
+
throw new HostDisagreementError(consensus);
|
|
174
|
+
}
|
|
175
|
+
return consensus.host;
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
HostDisagreementError,
|
|
179
|
+
agrees,
|
|
180
|
+
assertConsensus,
|
|
181
|
+
inspect,
|
|
182
|
+
normaliseHost
|
|
183
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "host-consensus",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reports whether every URL-parsing convention agrees on the host a URL names.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"url",
|
|
7
|
+
"ssrf",
|
|
8
|
+
"hostname",
|
|
9
|
+
"whatwg-url",
|
|
10
|
+
"rfc3986",
|
|
11
|
+
"security",
|
|
12
|
+
"validation",
|
|
13
|
+
"parser"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Tamer Kalla <tamerkalla@gmail.com>",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/tamerkalla/host-consensus.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/tamerkalla/host-consensus#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/tamerkalla/host-consensus/issues"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"main": "./dist/index.cjs",
|
|
38
|
+
"module": "./dist/index.js",
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"bin": {
|
|
41
|
+
"host-consensus": "./dist/cli.js"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"VERIFY.md"
|
|
46
|
+
],
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"build": "tsup"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/js-yaml": "4.0.9",
|
|
55
|
+
"@types/node": "22.18.12",
|
|
56
|
+
"js-yaml": "4.3.2",
|
|
57
|
+
"tsup": "8.5.0",
|
|
58
|
+
"typescript": "5.9.3",
|
|
59
|
+
"vitest": "3.2.7"
|
|
60
|
+
}
|
|
61
|
+
}
|