turva-llms-txt-validator 0.3.4 → 0.3.5
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/README.md +1 -1
- package/package.json +1 -1
- package/src/index.mjs +39 -4
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ The default validation requests two documents: `https://<host>/llms.txt` and `ht
|
|
|
123
123
|
|
|
124
124
|
- Timeout: eight seconds per document, shared across its redirect chain.
|
|
125
125
|
- Read limit: 256 KB per response.
|
|
126
|
-
- Redirects: up to four hops, over HTTPS, to the same host or its `www`/apex equivalent. Off-site redirects, embedded credentials and unsupported ports are rejected.
|
|
126
|
+
- Redirects: up to four hops, over HTTPS, to the same host or its `www`/apex equivalent. Off-site redirects, embedded credentials and unsupported ports are rejected. A rejected target is reported with its credentials and fragment removed and its query values masked.
|
|
127
127
|
- Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test` and `invalid` are rejected before a request is sent. Redirect targets are checked too.
|
|
128
128
|
|
|
129
129
|
**DNS resolution is not checked for private addresses.** A syntactically public domain can resolve to a private IP, so if you expose this package through a service that accepts untrusted domains, enforce private-address restrictions at the network layer. Local and CI runs use their own network policy. The hosted validator runs on the Cloudflare edge.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks that read pass or info and never move the summary, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
package/src/index.mjs
CHANGED
|
@@ -28,6 +28,41 @@ export function cut(s, n) {
|
|
|
28
28
|
return /[\uD800-\uDBFF]$/.test(s) ? s.slice(0, -1) : s;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// A redirect target the way a report may show it. The scheme, the host and the path stay readable,
|
|
32
|
+
// and the parts that can carry a secret do not: the user name and the password are removed, every
|
|
33
|
+
// query value becomes ***, and the fragment is dropped. The caller cuts the result to length
|
|
34
|
+
// afterwards, because a cut is not a mask. Up to 0.3.4 a refused redirect to
|
|
35
|
+
// https://user:password@host/ kept both in the check's detail and in the returned location (outside
|
|
36
|
+
// review, 2026-09-22). A target the URL parser refuses is masked by hand: nothing after the first ?
|
|
37
|
+
// or # survives, and when the target has a scheme or starts with two slashes, everything up to the
|
|
38
|
+
// last @ before them is shown as ***. Mirrors worker.js maskLocation().
|
|
39
|
+
export function maskLocation(href, base) {
|
|
40
|
+
let u = null;
|
|
41
|
+
try { u = new URL(href, base); } catch { u = null; }
|
|
42
|
+
if (u) {
|
|
43
|
+
u.username = "";
|
|
44
|
+
u.password = "";
|
|
45
|
+
for (const key of Array.from(u.searchParams.keys())) u.searchParams.set(key, "***");
|
|
46
|
+
u.hash = "";
|
|
47
|
+
return u.href;
|
|
48
|
+
}
|
|
49
|
+
let s = String(href).replace(/[\t\n\r]/g, "");
|
|
50
|
+
const stop = s.search(/[?#]/);
|
|
51
|
+
if (stop >= 0) s = s.slice(0, stop) + (s[stop] === "?" ? "?***" : "");
|
|
52
|
+
const scheme = /^[a-z][a-z0-9+.-]*:/i.exec(s);
|
|
53
|
+
const start = scheme ? scheme[0].length : 0;
|
|
54
|
+
let from = start;
|
|
55
|
+
while (from < s.length && (s[from] === "/" || s[from] === "\\")) from++;
|
|
56
|
+
// Without a scheme and two slashes there is no authority, and an @ is part of a path.
|
|
57
|
+
if (!scheme && from - start < 2) return s;
|
|
58
|
+
// Nothing tells which @ of a refused target ends the user information, so everything up to the
|
|
59
|
+
// last one is shown as ***. The mask is visible on purpose: three review rounds on 2026-09-22
|
|
60
|
+
// measured every rule that guessed, and each one either returned a password that holds a slash
|
|
61
|
+
// or made the host vanish without a trace.
|
|
62
|
+
const at = s.lastIndexOf("@");
|
|
63
|
+
return at < from ? s : s.slice(0, from) + "***@" + s.slice(at + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
export function normalizeHostInput(raw) {
|
|
32
67
|
let s = String(raw || "").trim().toLowerCase();
|
|
33
68
|
if (!s) return null;
|
|
@@ -77,13 +112,13 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
77
112
|
try { await res.body?.cancel(); } catch { /* the verdict below is the answer */ }
|
|
78
113
|
const loc = res.headers.get("location") || "";
|
|
79
114
|
if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
|
|
80
|
-
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(loc, 120) };
|
|
115
|
+
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(maskLocation(loc, url), 120) };
|
|
81
116
|
let next;
|
|
82
|
-
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(loc, 120) }; }
|
|
117
|
+
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(maskLocation(loc, url), 120) }; }
|
|
83
118
|
const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
|
|
84
119
|
const twin = (next.hostname.startsWith("www.") ? next.hostname.slice(4) : next.hostname) === reqApex;
|
|
85
|
-
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: cut(next.href, 120) };
|
|
86
|
-
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: cut(next.href, 120) };
|
|
120
|
+
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: cut(maskLocation(next.href), 120) };
|
|
121
|
+
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: cut(maskLocation(next.href), 120) };
|
|
87
122
|
if (!redirectedFrom) redirectedFrom = url;
|
|
88
123
|
url = next.href;
|
|
89
124
|
continue;
|