From 428d72d26f815ca0a4078f4c2494690434f871e4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 18 Oct 2026 09:37:17 +0200 Subject: [PATCH] chore: re-enable and extend unescaped-html-literal eslint rule (#39832) https://github.com/go-gitea/gitea/pull/38046 accidentally dropped `gitea/unescaped-html-literal` from the eslint config, so it has been off since. Raised in https://github.com/go-gitea/gitea/pull/39829#issuecomment-6246720235. - Re-enable the rule, exempting e2e tests like unit tests - Detect tags anywhere in strings and template chunks, not only at the start - Allow `htmlRaw` templates, they escape like `html` - Fix the existing violations, which escapes the heatmap tooltip and shortcut keys --- eslint.config.ts | 4 ++++ .../unescaped-html-literal.test.ts | 19 +++++++++++++++++++ tools/eslint-rules/unescaped-html-literal.ts | 10 +++++----- web_src/js/components/ActivityHeatmap.vue | 3 ++- web_src/js/markup/mermaid.ts | 2 +- web_src/js/modules/devtest.ts | 2 +- web_src/js/modules/shortcut.ts | 2 +- web_src/js/modules/tippy.ts | 3 +-- 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 35e26ed111d..50c1bcecd7f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -311,6 +311,7 @@ export default defineConfig([ 'func-names': [0], 'func-style': [0], 'getter-return': [2], + 'gitea/unescaped-html-literal': [2], 'grouped-accessor-pairs': [2], 'guard-for-in': [0], 'id-denylist': [0], @@ -1239,6 +1240,9 @@ export default defineConfig([ { files: ['*', 'tools/**/*', 'tests/**/*'], languageOptions: {globals: globals.nodeBuiltin}, + rules: { + 'gitea/unescaped-html-literal': [0], + }, }, { files: ['web_src/**/*'], diff --git a/tools/eslint-rules/unescaped-html-literal.test.ts b/tools/eslint-rules/unescaped-html-literal.test.ts index af3d473e764..636279bbe94 100644 --- a/tools/eslint-rules/unescaped-html-literal.test.ts +++ b/tools/eslint-rules/unescaped-html-literal.test.ts @@ -28,6 +28,10 @@ ruleTester.run('unescaped-html-literal', rule, { code: 'const helloTemplate = (name) => html`
Hello ${name}!
`;', languageOptions: {ecmaVersion: 2017}, }, + { + code: 'const attr = htmlRaw` width="${width}"`, img = htmlRaw``, cmp = "a < b";', + languageOptions: {ecmaVersion: 2017}, + }, ], invalid: [ { @@ -66,6 +70,21 @@ ruleTester.run('unescaped-html-literal', rule, { }, ], }, + { + code: "const rowHTML = `${cells}`, tailHTML = '', textHTML = 'Hello World'", + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, { code: 'const helloHTML = foo`
Hello ${name}!
`', languageOptions: {ecmaVersion: 2017}, diff --git a/tools/eslint-rules/unescaped-html-literal.ts b/tools/eslint-rules/unescaped-html-literal.ts index 87c40f0518e..3ae51a97ecc 100644 --- a/tools/eslint-rules/unescaped-html-literal.ts +++ b/tools/eslint-rules/unescaped-html-literal.ts @@ -2,7 +2,8 @@ // https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js import type {JSRuleDefinition, JSRuleDefinitionTypeOptions} from 'eslint'; -const htmlOpenTag = /^\s*<[a-zA-Z]/; +const htmlTag = /<\/?[a-z][\w-]*[\s/>]/i; +const escapingTags = new Set(['html', 'htmlRaw']); const rule: JSRuleDefinition = { meta: { @@ -14,7 +15,7 @@ const rule: JSRuleDefinition = { create: (context) => ({ Literal(node) { - if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return; + if (typeof node.value !== 'string' || !htmlTag.test(node.value)) return; context.report({ node, @@ -22,11 +23,10 @@ const rule: JSRuleDefinition = { }); }, TemplateLiteral(node) { - const templateStart = node.quasis[0]?.value.raw; - if (!templateStart || !htmlOpenTag.test(templateStart)) return; + if (node.quasis.every((quasi) => !htmlTag.test(quasi.value.raw))) return; const parent = node.parent; - if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return; + if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && escapingTags.has(parent.tag.name)) return; context.report({ node, diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index 48d92229314..067fa15d4dd 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -3,6 +3,7 @@ import {computed, onBeforeUnmount, onMounted} from 'vue'; import tippy, {createSingleton} from 'tippy.js'; import type {CreateSingletonInstance, Instance} from 'tippy.js'; import {getCurrentLocale} from '../utils.ts'; +import {html} from '../utils/html.ts'; type HeatmapValue = {date: Date; count: number}; type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string}; @@ -96,7 +97,7 @@ const grid = computed(() => { date: new Date(cursor), colorIndex: hit ? hit.colorIndex : 0, ariaLabel: `${head} ${on} ${dateStr}`, - tooltip: `${head} ${on} ${dateStr}`, + tooltip: html`${head} ${on} ${dateStr}`, }); cursor.setDate(cursor.getDate() + 1); } diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index d81fb1c7ff0..efcf574ff8b 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -110,7 +110,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promisebody { margin: 0; overflow: hidden; } #mermaid { display: block; margin: 0 auto; }`; const viewBoxHeight = svgNode.viewBox.baseVal?.height; if (viewBoxHeight) iframe.style.height = `${Math.max(Math.ceil(viewBoxHeight), 85)}px`; // min-height keeps the buttons from overlapping diff --git a/web_src/js/modules/devtest.ts b/web_src/js/modules/devtest.ts index 8efe7e99596..f4f225c4037 100644 --- a/web_src/js/modules/devtest.ts +++ b/web_src/js/modules/devtest.ts @@ -85,7 +85,7 @@ function initDevtestAnsiRender(container: HTMLElement) { ...withSource(`first${esc}[Ksecond${esc}[2Jthird`), ...withSource(`cursor ${esc}[3Amovement, private ${esc}[?25lCSI, ${esc}]0;title${esc}\\titles, ${esc}Pquery${esc}\\strings, truncated${esc}[38;5;`), ...withSource('Reading... 10%\b\b\b100%'), - ...withSource(' & "quotes", and a bare url https://example.com'), + ...withSource(' & "quotes", and a bare url https://example.com'), // eslint-disable-line gitea/unescaped-html-literal -- plain text that the console must escape ' ', `${esc}[31man unterminated color`, 'carries into the following lines', diff --git a/web_src/js/modules/shortcut.ts b/web_src/js/modules/shortcut.ts index 58d2ac26a4a..44622228b1e 100644 --- a/web_src/js/modules/shortcut.ts +++ b/web_src/js/modules/shortcut.ts @@ -108,7 +108,7 @@ function showShortcutHelp() { seenShortcuts.add(shortcutIdentifier); let keysHtml = ''; for (const keys of shortcutKeys.split(' ')) { - keysHtml += `${keys} `; + keysHtml += html`${keys} `; } list.append(createElementFromHTML(html`${htmlRaw(keysHtml)}${label}`)); } diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 531cfa9ceb5..68865bbfbd5 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -166,8 +166,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc let allowHTML = target.getAttribute('data-tooltip-render') === 'html'; if (!allowHTML && typeof content === 'string') { - content = htmlEscape(content); - content = content.replace(/\n/g, '
'); + content = htmlEscape(content).split('\n').join(html`
`); allowHTML = true; }