summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-07-28 05:36:57 +0300
committerYuriy Andamasov <yuriy@vyos.io>2026-07-28 05:36:57 +0300
commit0c9348035b583f3cd5fca72937a7ec1a61d7df45 (patch)
tree6113a587f5dbbe4b719ad2bbcd940f29395d89c3
parentcfca705f325a073c00e221a080eff968112d7653 (diff)
downloadvyos-documentation-claude/vyos-docs-security-remediate-2cff61.tar.gz
vyos-documentation-claude/vyos-docs-security-remediate-2cff61.zip
security: normalize percent escapes per run, not per segmentclaude/vyos-docs-security-remediate-2cff61
Round-2 adversarial finding (Codex, medium): whole-segment decode meant one malformed escape (a%20b%zz) threw for the segment and double-encoded the valid escapes beside it. encodeSegment now decodes+re-encodes each well-formed %HH run independently; literal spans (including a bare '%') always pass through encodeURIComponent, so taint neutralization holds unconditionally; a run decoding to invalid UTF-8 stays verbatim (already pure %HH text). workers suite 108/108 (+2 discriminating regression tests). 🤖 Generated by [robots](https://vyos.io)
-rw-r--r--docs/_static/js/version-picker.js34
-rw-r--r--workers/picker-test/picker.test.ts14
2 files changed, 37 insertions, 11 deletions
diff --git a/docs/_static/js/version-picker.js b/docs/_static/js/version-picker.js
index d6faa93d..84f68fd6 100644
--- a/docs/_static/js/version-picker.js
+++ b/docs/_static/js/version-picker.js
@@ -35,21 +35,33 @@
return null;
}
+ /* Normalize one path segment: each well-formed %HH run is decoded and re-encoded on its
+ * own — per run, not per segment, so one malformed escape cannot double-encode the valid
+ * ones beside it (location.pathname hands escapes back verbatim, and blind re-encoding
+ * would turn %2E into %252E and miss on the HEAD probe). Literal spans, including a bare
+ * '%', always go through encodeURIComponent, so taint neutralization holds unconditionally;
+ * a run decoding to invalid UTF-8 is kept verbatim, being already pure %HH text.
+ * Decoding cannot resurrect a dot-segment: the URL parser resolves '.' / '..' and their
+ * percent-encoded forms during navigation, so pathname never presents them. */
+ function encodeSegment(seg) {
+ var out = '';
+ var re = /(?:%[0-9A-Fa-f]{2})+/g;
+ var last = 0;
+ var m;
+ while ((m = re.exec(seg))) {
+ out += encodeURIComponent(seg.slice(last, m.index));
+ try { out += encodeURIComponent(decodeURIComponent(m[0])); } catch (e) { out += m[0]; }
+ last = m.index + m[0].length;
+ }
+ return out + encodeURIComponent(seg.slice(last));
+ }
+
/* Percent-encode a multi-segment path one segment at a time, so the '/' separators
* survive. Real docs paths are plain ASCII sphinx slugs (letters/digits/-/_/./html),
* where this is a no-op — it exists to keep DOM-derived text (location.pathname,
- * select.value) from reaching a location.href sink unescaped. Each segment is decoded
- * first, because location.pathname hands back well-formed escapes verbatim: without it
- * a deep link like /index%2Ehtml would re-encode to %252E and miss on the HEAD probe.
- * A malformed escape ('%zz') throws, and the raw segment is encoded defensively.
- * Decoding cannot resurrect a dot-segment: the URL parser resolves '.' / '..' and their
- * percent-encoded forms during navigation, so pathname never presents them. */
+ * select.value) from reaching a location.href sink unescaped. */
function encodePath(rest) {
- return rest.split('/').map(function (seg) {
- var decoded;
- try { decoded = decodeURIComponent(seg); } catch (e) { decoded = seg; }
- return encodeURIComponent(decoded);
- }).join('/');
+ return rest.split('/').map(function (seg) { return encodeSegment(seg); }).join('/');
}
function targetUrlFor(loc, targetSlug) {
diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts
index 7d7594cc..560fd0d2 100644
--- a/workers/picker-test/picker.test.ts
+++ b/workers/picker-test/picker.test.ts
@@ -117,6 +117,20 @@ describe("URL construction percent-encodes hostile path components", () => {
for (const c of HOSTILE) expect(url).not.toContain(c);
});
+ // Normalization runs per %HH run, not per segment: a whole-segment decode throws on the
+ // malformed escape and then double-encodes the valid one beside it (a%2520b%25zz).
+ it("encodePath normalizes each escape run independently in a mixed-validity segment", () => {
+ const url = P.encodePath("a%20b%zz/x");
+ expect(url).toBe("a%20b%25zz/x");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("encodePath keeps an invalid-UTF-8 escape run verbatim (already pure %HH text)", () => {
+ const url = P.encodePath("x%E0%A4y.html");
+ expect(url).toBe("x%E0%A4y.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
it("targetUrlFor encodes a markup-injecting target slug", () => {
const url = P.targetUrlFor(loc, '"><img src=x>');
expect(url).toBe("/en/%22%3E%3Cimg%20src%3Dx%3E/cli/index.html");