diff --git a/.eslintrc.json b/.eslintrc.json index 5cfcf250fb..79b462d99c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -88,11 +88,14 @@ "unicorn/no-null": "off", "unicorn/no-typeof-undefined": "off", "unicorn/no-unused-properties": "error", + "unicorn/numeric-separators-style": "off", "unicorn/prefer-array-flat": "off", + "unicorn/prefer-at": "off", "unicorn/prefer-dom-node-dataset": "off", "unicorn/prefer-module": "off", "unicorn/prefer-query-selector": "off", "unicorn/prefer-spread": "off", + "unicorn/prefer-string-replace-all": "off", "unicorn/prevent-abbreviations": "off" }, "overrides": [ @@ -189,8 +192,7 @@ }, "rules": { "no-new": "off", - "unicorn/no-array-for-each": "off", - "unicorn/numeric-separators-style": "off" + "unicorn/no-array-for-each": "off" } }, { diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fcef85cd0..53d5f9f8ee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -95,7 +95,7 @@ _Note: Please transform `- [ ]` into `- (NA)` in the description when things are - then, if bumping a minor or major version: - [ ] Manually change `version_short` in `package.json` - [ ] Add docs version to `site/data/docs-versions.yml` - - [ ] Manually change `docs_version` in `config.yml` and other references to the previous version + - [ ] Manually change `docs_version` in `hugo.yml` and other references to the previous version - [ ] Update redirects in docs frontmatter (`site/content/docs/_index.html`?) - [ ] Move `site/content/docs/5.x` to `site/content/docs/5.x+1` - [ ] Increment `site/static/docs/{version}` version diff --git a/build/generate-sri.js b/build/generate-sri.js index c90695823e..93106b0fe9 100644 --- a/build/generate-sri.js +++ b/build/generate-sri.js @@ -18,11 +18,11 @@ const sh = require('shelljs') sh.config.fatal = true -const configFile = path.join(__dirname, '../config.yml') +const configFile = path.join(__dirname, '../hugo.yml') // Array of objects which holds the files to generate SRI hashes for. // `file` is the path from the root folder -// `configPropertyName` is the config.yml variable's name of the file +// `configPropertyName` is the hugo.yml variable's name of the file const files = [ { file: 'dist/css/boosted.min.css', diff --git a/config.yml b/hugo.yml similarity index 96% rename from config.yml rename to hugo.yml index 64ebaf86a0..a25d39c21d 100644 --- a/config.yml +++ b/hugo.yml @@ -100,9 +100,9 @@ params: js_hash: "sha384-Ay44pAtq92iRrbFnSxWeYAfGcj6hdwTyM6eXJur4pM5vhr0FcqS6QXXk4iTDk4YB" js_bundle: "https://cdn.jsdelivr.net/npm/boosted@5.3.0-alpha3/dist/js/boosted.bundle.min.js" js_bundle_hash: "sha384-COkOgwCu1CnSnNJa4+/DDfHEtuu7dOnfPwwKqolB1lnFcj9turrs6yqTCwc3KeRo" - popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.7/dist/umd/popper.min.js" - popper_hash: "sha384-zYPOMqeu1DAVkHiLqWBUTcbYfZ8osu1Nd6Z89ify25QV9guujx43ITvfi12/QExE" - popper_esm: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.7/dist/esm/popper.min.js" + popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" + popper_hash: "sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" + popper_esm: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/esm/popper.min.js" focus_visible: "https://cdn.jsdelivr.net/npm/focus-visible@5.2.0/dist/focus-visible.min.js" focus_visible_hash: "sha384-xRa5B8rCDfdg0npZcxAh+RXswrbFk3g6dlHVeABeluN8EIwdyljz/LqJgc2R3KNA" diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 0b1747c8ae..69de7151be 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -208,11 +208,11 @@ class ScrollSpy extends BaseComponent { continue } - const observableSection = SelectorEngine.findOne(anchor.hash, this._element) + const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element) // ensure that the observableSection exists & is visible if (isVisible(observableSection)) { - this._targetLinks.set(anchor.hash, anchor) + this._targetLinks.set(decodeURI(anchor.hash), anchor) this._observableSections.set(anchor.hash, observableSection) } } diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js index 5a07a67c1a..d2b08082ca 100644 --- a/js/src/util/sanitizer.js +++ b/js/src/util/sanitizer.js @@ -5,47 +5,6 @@ * -------------------------------------------------------------------------- */ -const uriAttributes = new Set([ - 'background', - 'cite', - 'href', - 'itemtype', - 'longdesc', - 'poster', - 'src', - 'xlink:href' -]) - -/** - * A pattern that recognizes a commonly useful subset of URLs that are safe. - * - * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts - */ -const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i - -/** - * A pattern that matches safe data URLs. Only matches image, video and audio types. - * - * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts - */ -const DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i - -const allowedAttribute = (attribute, allowedAttributeList) => { - const attributeName = attribute.nodeName.toLowerCase() - - if (allowedAttributeList.includes(attributeName)) { - if (uriAttributes.has(attributeName)) { - return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue) || DATA_URL_PATTERN.test(attribute.nodeValue)) - } - - return true - } - - // Check if a regular expression validates the attribute. - return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp) - .some(regex => regex.test(attributeName)) -} - // js-docs-start allow-list const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i @@ -84,6 +43,42 @@ export const DefaultAllowlist = { } // js-docs-end allow-list +const uriAttributes = new Set([ + 'background', + 'cite', + 'href', + 'itemtype', + 'longdesc', + 'poster', + 'src', + 'xlink:href' +]) + +/** + * A pattern that recognizes URLs that are safe wrt. XSS in URL navigation + * contexts. + * + * Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38 + */ +// eslint-disable-next-line unicorn/better-regex +const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i + +const allowedAttribute = (attribute, allowedAttributeList) => { + const attributeName = attribute.nodeName.toLowerCase() + + if (allowedAttributeList.includes(attributeName)) { + if (uriAttributes.has(attributeName)) { + return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue)) + } + + return true + } + + // Check if a regular expression validates the attribute. + return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp) + .some(regex => regex.test(attributeName)) +} + export function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) { if (!unsafeHtml.length) { return unsafeHtml @@ -102,7 +97,6 @@ export function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) { if (!Object.keys(allowList).includes(elementName)) { element.remove() - continue } diff --git a/js/tests/unit/scrollspy.spec.js b/js/tests/unit/scrollspy.spec.js index 070448c17e..ecbd9522c0 100644 --- a/js/tests/unit/scrollspy.spec.js +++ b/js/tests/unit/scrollspy.spec.js @@ -940,5 +940,39 @@ describe('ScrollSpy', () => { }, 100) link.click() }) + + it('should smoothscroll to observable with anchor link that contains a french word as id', done => { + fixtureEl.innerHTML = [ + '', + '
', + '
div 1
', + '
' + ].join('') + + const div = fixtureEl.querySelector('.content') + const link = fixtureEl.querySelector('[href="#présentation"]') + const observable = fixtureEl.querySelector('#présentation') + const clickSpy = getElementScrollSpy(div) + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1, + smoothScroll: true + }) + + setTimeout(() => { + if (div.scrollTo) { + expect(clickSpy).toHaveBeenCalledWith({ top: observable.offsetTop - div.offsetTop, behavior: 'smooth' }) + } else { + expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop) + } + + done() + }, 100) + link.click() + }) }) }) diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js index 55e9b63364..2b21ef2e19 100644 --- a/js/tests/unit/util/sanitizer.spec.js +++ b/js/tests/unit/util/sanitizer.spec.js @@ -10,17 +10,75 @@ describe('Sanitizer', () => { expect(result).toEqual(empty) }) - it('should sanitize template by removing tags with XSS', () => { - const template = [ - '
', - ' Click me', - ' Some content', - '
' - ].join('') - - const result = sanitizeHtml(template, DefaultAllowlist, null) + it('should retain tags with valid URLs', () => { + const validUrls = [ + '', + 'http://abc', + 'HTTP://abc', + 'https://abc', + 'HTTPS://abc', + 'ftp://abc', + 'FTP://abc', + 'mailto:me@example.com', + 'MAILTO:me@example.com', + 'tel:123-123-1234', + 'TEL:123-123-1234', + 'sip:me@example.com', + 'SIP:me@example.com', + '#anchor', + '/page1.md', + 'http://JavaScript/my.js', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', // Truncated. + 'data:video/webm;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:audio/opus;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'unknown-scheme:abc' + ] + + for (const url of validUrls) { + const template = [ + '
', + ` Click me`, + ' Some content', + '
' + ].join('') + + const result = sanitizeHtml(template, DefaultAllowlist, null) + + expect(result).toContain(`href="${url}"`) + } + }) - expect(result).not.toContain('href="javascript:alert(7)') + it('should sanitize template by removing tags with XSS', () => { + const invalidUrls = [ + // eslint-disable-next-line no-script-url + 'javascript:alert(7)', + // eslint-disable-next-line no-script-url + 'javascript:evil()', + // eslint-disable-next-line no-script-url + 'JavaScript:abc', + ' javascript:abc', + ' \n Java\n Script:abc', + 'javascript:', + 'javascript:', + 'j avascript:', + 'javascript:', + 'javascript:', + 'jav ascript:alert();', + 'jav\u0000ascript:alert();' + ] + + for (const url of invalidUrls) { + const template = [ + '
', + ` Click me`, + ' Some content', + '
' + ].join('') + + const result = sanitizeHtml(template, DefaultAllowlist, null) + + expect(result).not.toContain(`href="${url}"`) + } }) it('should sanitize template and work with multiple regex', () => { diff --git a/js/tests/visual/scrollspy.html b/js/tests/visual/scrollspy.html index 4f27517431..f0d3ae59e9 100644 --- a/js/tests/visual/scrollspy.html +++ b/js/tests/visual/scrollspy.html @@ -29,6 +29,7 @@
  • One
  • Two
  • Three
  • +
  • Présentation