From 95e9ad86def9b1f33d65a422e1235011e0fb1225 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Tue, 28 Jul 2026 12:16:46 +0300 Subject: security: remediate CodeQL code-scanning alerts (#2171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: remediate CodeQL code-scanning alerts (picker XSS sinks, test sanitization, vendored DataTables exclusion) Remediates all 11 open CodeQL alerts on the default branch: - version-picker.js (js/xss-through-dom, alerts 1-3): percent-encode every DOM-derived path component (select.value, parsed location segments) at URL construction time via encodePath()/langUrlFor(), and tighten the parseLocation slug charset to [A-Za-z0-9._-]. No-op on legitimate sphinx slugs — URLs stay byte-identical (asserted by tests). - workers/apex/test/manifest.test.ts (js/incomplete-multi-character- sanitization, alert 6): strip HTML comments from the root.html fixture repeatedly to a fixpoint instead of a single pass. - docs/_static/js/datatables.js (alerts 4,5,7-11): excluded from CodeQL analysis via .github/codeql/codeql-config.yml (new codeql-cfg-path input to the fleet reusable workflow). The file is vendored stock DataTables 1.11.5; the flagged helpers are display/sort normalization, not sanitization boundaries. Excluding keeps the vendored copy byte-identical to upstream instead of hand-patching it. Adds 9 picker tests (hostile-input encoding + slug-charset accept/reject); workers suite 103/103 green. 🤖 Generated by [robots](https://vyos.io) * security: normalize pre-existing percent escapes in encodePath Adversarial-review finding (Codex, medium): location.pathname returns well-formed escapes verbatim, so blind encodeURIComponent double-encoded them (%2E -> %252E), broke the HEAD probe on escaped deep links, and dumped the user at the version root. Each segment is now decoded first (malformed escapes keep the raw segment — no throw), then re-encoded to canonical single encoding. Decoding cannot resurrect dot-segments: the URL parser resolves '.'/'..' and their percent-encoded forms during navigation, so pathname never presents them (verified against the WHATWG parser in Node). workers suite 106/106 (+2 regression tests, mutation-verified). 🤖 Generated by [robots](https://vyos.io) * security: normalize percent escapes per run, not per segment 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) --- docs/_static/js/version-picker.js | 52 ++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) (limited to 'docs/_static') diff --git a/docs/_static/js/version-picker.js b/docs/_static/js/version-picker.js index f13ad60f..84f68fd6 100644 --- a/docs/_static/js/version-picker.js +++ b/docs/_static/js/version-picker.js @@ -6,8 +6,10 @@ // Deliberately does not match /pr-/ preview prefixes — previews are single-version, // so the picker has nothing to switch between and stays hidden there by design. + // The (?!\.\.?\/) guard rejects the dot-segments "." and ".." as slugs, which would + // otherwise escape the /// tree once the browser normalizes the URL. function parseLocation(pathname) { - var m = pathname.match(/^\/([a-z]{2}(?:_[A-Z]{2})?)\/([^/]+)\/(.*)$/); + var m = pathname.match(/^\/([a-z]{2}(?:_[A-Z]{2})?)\/(?!\.\.?\/)([A-Za-z0-9._-]+)\/(.*)$/); if (!m) return null; return { lang: m[1], slug: m[2], rest: m[3] }; } @@ -33,8 +35,38 @@ 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. */ + function encodePath(rest) { + return rest.split('/').map(function (seg) { return encodeSegment(seg); }).join('/'); + } + function targetUrlFor(loc, targetSlug) { - return '/' + loc.lang + '/' + targetSlug + '/' + loc.rest; + return '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(targetSlug) + + '/' + encodePath(loc.rest); } /* Full navigation URL for a version switch: same path on the target version @@ -44,6 +76,13 @@ return targetUrlFor(loc, targetSlug) + (search || '') + (hash || ''); } + /* Mirror of targetUrlFor for the language switch: swaps the lang segment while + * keeping the current version slug and path. */ + function langUrlFor(loc, langCode) { + return '/' + encodeURIComponent(langCode) + '/' + encodeURIComponent(loc.slug) + + '/' + encodePath(loc.rest); + } + /* ---- DOM layer (no execution at import time) ---- */ function bannerText(b, manifest) { if (b.kind === 'dev') return 'You are reading the development (rolling) docs.'; @@ -81,7 +120,8 @@ sel.addEventListener('change', function () { var search = window.location.search, hash = window.location.hash; var target = navUrlFor(loc, sel.value, search, hash); - var fallback = '/' + loc.lang + '/' + sel.value + '/' + (search || '') + (hash || ''); + var fallback = '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(sel.value) + + '/' + (search || '') + (hash || ''); fetch(targetUrlFor(loc, sel.value), { method: 'HEAD' }) .then(function (r) { window.location.href = (r.status === 404) ? fallback : target; @@ -110,7 +150,7 @@ sel.appendChild(o); }); sel.addEventListener('change', function () { - window.location.href = '/' + sel.value + '/' + loc.slug + '/' + loc.rest + + window.location.href = langUrlFor(loc, sel.value) + window.location.search + window.location.hash; }); anchor.appendChild(sel); @@ -145,8 +185,8 @@ } window.VyOSVersionPicker = { - parseLocation: parseLocation, bannerFor: bannerFor, - targetUrlFor: targetUrlFor, navUrlFor: navUrlFor, init: init, + parseLocation: parseLocation, bannerFor: bannerFor, encodePath: encodePath, + targetUrlFor: targetUrlFor, navUrlFor: navUrlFor, langUrlFor: langUrlFor, init: init, }; if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('DOMContentLoaded', init); -- cgit v1.2.3