turva-llms-txt-validator 0.3.5 → 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. A rejected target is reported with its credentials and fragment removed and its query values masked.
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.5",
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
@@ -63,6 +63,14 @@ export function maskLocation(href, base) {
63
63
  return at < from ? s : s.slice(0, from) + "***@" + s.slice(at + 1);
64
64
  }
65
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
+
66
74
  export function normalizeHostInput(raw) {
67
75
  let s = String(raw || "").trim().toLowerCase();
68
76
  if (!s) return null;
@@ -72,7 +80,10 @@ export function normalizeHostInput(raw) {
72
80
  if (u.protocol !== "https:" && u.protocol !== "http:") return null;
73
81
  if (u.port && u.port !== "443" && u.port !== "80") return null;
74
82
  if (u.username || u.password) return null;
75
- 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;
76
87
  }
77
88
 
78
89
  export function isValidPublicHost(host) {
@@ -80,7 +91,9 @@ export function isValidPublicHost(host) {
80
91
  if (host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
81
92
  if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host)) return false;
82
93
  const tld = host.split(".").pop();
83
- 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;
84
97
  return true;
85
98
  }
86
99
 
@@ -272,7 +285,10 @@ export function validateLlmsTxt(f) {
272
285
  add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
273
286
  return checks;
274
287
  }
275
- 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");
276
292
  const ct = (f.contentType || "").toLowerCase();
277
293
  const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
278
294
  if (looksHtml) {
@@ -1254,5 +1270,9 @@ export async function validateHost(input, opts = {}) {
1254
1270
  discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
1255
1271
  }
1256
1272
  const checks = validateLlmsTxt(fetched).concat(discovery);
1257
- 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 };
1258
1278
  }