turva-llms-txt-validator 0.3.4 → 0.3.6
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 +2 -2
- package/package.json +1 -1
- package/src/index.mjs +63 -8
package/README.md
CHANGED
|
@@ -123,8 +123,8 @@ 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.
|
|
127
|
-
- Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test` and `
|
|
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 redirect target is reported with its credentials and fragment removed and its query values masked, whether the redirect was followed or rejected.
|
|
127
|
+
- Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test`, `invalid`, `arpa` and `onion` are rejected before a request is sent. One trailing dot on the host you give, as in `example.com.`, is removed first. 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.
|
|
130
130
|
|
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.6",
|
|
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,49 @@ 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
|
+
|
|
66
|
+
// Unicode bidi controls (U+202A to U+202E, U+2066 to U+2069) have no place in a check's
|
|
67
|
+
// detail: text quoted from the fetched file could otherwise read in a different order here
|
|
68
|
+
// than in the file itself, and the right-to-left override is the classic way to do that
|
|
69
|
+
// (round 19, V6-U1). Mirrors worker.js stripBidi().
|
|
70
|
+
export function stripBidi(s) {
|
|
71
|
+
return String(s).replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
export function normalizeHostInput(raw) {
|
|
32
75
|
let s = String(raw || "").trim().toLowerCase();
|
|
33
76
|
if (!s) return null;
|
|
@@ -37,7 +80,10 @@ export function normalizeHostInput(raw) {
|
|
|
37
80
|
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
38
81
|
if (u.port && u.port !== "443" && u.port !== "80") return null;
|
|
39
82
|
if (u.username || u.password) return null;
|
|
40
|
-
|
|
83
|
+
// One trailing dot names the same host in absolute form, so "example.com." is read as
|
|
84
|
+
// example.com instead of refused (round 19, K7-P5). Mirrors worker.js normalizeHostInput().
|
|
85
|
+
const typedHost = u.hostname;
|
|
86
|
+
return typedHost.length > 1 && typedHost.endsWith(".") ? typedHost.slice(0, -1) : typedHost;
|
|
41
87
|
}
|
|
42
88
|
|
|
43
89
|
export function isValidPublicHost(host) {
|
|
@@ -45,7 +91,9 @@ export function isValidPublicHost(host) {
|
|
|
45
91
|
if (host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
|
|
46
92
|
if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host)) return false;
|
|
47
93
|
const tld = host.split(".").pop();
|
|
48
|
-
|
|
94
|
+
// arpa and onion since round 19 (P3): home.arpa is the home-network name of RFC 8375,
|
|
95
|
+
// in-addr.arpa and ip6.arpa name addresses, and an onion name never resolves on the public web.
|
|
96
|
+
if (["localhost", "local", "internal", "home", "lan", "corp", "test", "invalid", "arpa", "onion"].includes(tld)) return false;
|
|
49
97
|
return true;
|
|
50
98
|
}
|
|
51
99
|
|
|
@@ -77,13 +125,13 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
77
125
|
try { await res.body?.cancel(); } catch { /* the verdict below is the answer */ }
|
|
78
126
|
const loc = res.headers.get("location") || "";
|
|
79
127
|
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) };
|
|
128
|
+
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(maskLocation(loc, url), 120) };
|
|
81
129
|
let next;
|
|
82
|
-
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(loc, 120) }; }
|
|
130
|
+
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(maskLocation(loc, url), 120) }; }
|
|
83
131
|
const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
|
|
84
132
|
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) };
|
|
133
|
+
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: cut(maskLocation(next.href), 120) };
|
|
134
|
+
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: cut(maskLocation(next.href), 120) };
|
|
87
135
|
if (!redirectedFrom) redirectedFrom = url;
|
|
88
136
|
url = next.href;
|
|
89
137
|
continue;
|
|
@@ -237,7 +285,10 @@ export function validateLlmsTxt(f) {
|
|
|
237
285
|
add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
|
|
238
286
|
return checks;
|
|
239
287
|
}
|
|
240
|
-
|
|
288
|
+
// An accepted redirect is shown masked as well (round 19, K7-2, mirrored from worker.js
|
|
289
|
+
// v3.164.0): a query value or a fragment in a same-host Location used to reach this
|
|
290
|
+
// detail as sent.
|
|
291
|
+
add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + maskLocation(f.redirectedFrom) + " to " + maskLocation(f.finalUrl) : "HTTP 200");
|
|
241
292
|
const ct = (f.contentType || "").toLowerCase();
|
|
242
293
|
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
|
|
243
294
|
if (looksHtml) {
|
|
@@ -1219,5 +1270,9 @@ export async function validateHost(input, opts = {}) {
|
|
|
1219
1270
|
discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
|
|
1220
1271
|
}
|
|
1221
1272
|
const checks = validateLlmsTxt(fetched).concat(discovery);
|
|
1222
|
-
|
|
1273
|
+
// Text quoted from the fetched file leaves without bidirectional controls (round 19,
|
|
1274
|
+
// V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
|
|
1275
|
+
// read in another order here than in the file itself.
|
|
1276
|
+
const stripped = checks.map((c) => (typeof c.detail === "string" ? { ...c, detail: stripBidi(c.detail) } : c));
|
|
1277
|
+
return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(stripped), checks: stripped };
|
|
1223
1278
|
}
|