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 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 `invalid` are rejected before a request is sent. Redirect targets are checked too.
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.4",
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
- return u.hostname;
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
- if (["localhost", "local", "internal", "home", "lan", "corp", "test", "invalid"].includes(tld)) return false;
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
- add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + f.redirectedFrom + " to " + f.finalUrl : "HTTP 200");
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
- return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(checks), checks };
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
  }