From b48196a708533258a82cec9e3464ebdcd933f51a Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:31:43 -0500 Subject: [PATCH 1/9] [wip] Prototype XSS prevention rules --- .github/workflows/build.yml | 10 ++++++++++ .semgrep/xss-prevention.yml | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .semgrep/xss-prevention.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 108ccde170..8af271c66f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,3 +30,13 @@ jobs: echo "::remove-matcher owner=eslint-stylish::" - run: npm run all - run: npm run test + + semgrep: + name: semgrep-oss/scan + runs-on: ubuntu-latest + container: + image: semgrep/semgrep + if: (github.actor != 'dependabot[bot]') + steps: + - uses: actions/checkout@v4 + - run: semgrep scan --config auto --config .semgrep/ diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml new file mode 100644 index 0000000000..1a1fa72a08 --- /dev/null +++ b/.semgrep/xss-prevention.yml @@ -0,0 +1,23 @@ +rules: + - id: d3-unsanitized-html + languages: [javascript, typescript] + message: | + Potential XSS: .html() called with unsanitized content. + Use utilSanitizeHTML() for external/user data, .text() for plain text, + or l10n.tHtml() for trusted localization strings. + severity: WARNING + patterns: + - pattern: $SEL.html($CONTENT) + # Exclude safe patterns: + - pattern-not: $SEL.html(utilSanitizeHTML(...)) + - pattern-not: $SEL.html(l10n.tHtml(...)) + # Exclude clearing/resetting content: + - pattern-not: $SEL.html('') + - pattern-not: $SEL.html("") + - pattern-not: $SEL.html(' ') + - pattern-not: $SEL.html(" ") + # Exclude reading innerHTML (no arguments or getting value): + - pattern-not: $SEL.html() + paths: + include: + - modules/ From fed4a46d368c8d51734421aea835e747914debf5 Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:34:53 -0500 Subject: [PATCH 2/9] Test XSS rules --- modules/ui/note_comments.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index f5fbd500b9..0ca5dd7934 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -67,7 +67,7 @@ export function uiNoteComments(context) { mainEnter .append('div') .attr('class', 'comment-text') - .html(d => utilSanitizeHTML(d.html)) + .html(d => d.html) .selectAll('a') .attr('rel', 'noopener nofollow') .attr('target', '_blank'); From 91487e5fed04e48f2923384a07afadbf53d7d9bd Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:38:27 -0500 Subject: [PATCH 3/9] Fail on error --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8af271c66f..c0dd35b448 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,4 +39,4 @@ jobs: if: (github.actor != 'dependabot[bot]') steps: - uses: actions/checkout@v4 - - run: semgrep scan --config auto --config .semgrep/ + - run: semgrep scan --error --config .semgrep/ From ee40777f8ef238839f75b5a98a79daec9f8042cb Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:41:58 -0500 Subject: [PATCH 4/9] Additional patterns --- .semgrep/xss-prevention.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml index 1a1fa72a08..daf43a8a61 100644 --- a/.semgrep/xss-prevention.yml +++ b/.semgrep/xss-prevention.yml @@ -4,19 +4,23 @@ rules: message: | Potential XSS: .html() called with unsanitized content. Use utilSanitizeHTML() for external/user data, .text() for plain text, - or l10n.tHtml() for trusted localization strings. + or l10n.t()/l10n.tHtml() for trusted localization strings. severity: WARNING patterns: - pattern: $SEL.html($CONTENT) - # Exclude safe patterns: + # Exclude safe patterns - sanitized content: - pattern-not: $SEL.html(utilSanitizeHTML(...)) + # Exclude safe patterns - localization (trusted developer content): - pattern-not: $SEL.html(l10n.tHtml(...)) + - pattern-not: $SEL.html(l10n.t(...)) + # Exclude safe patterns - marked.parse on localization content: + - pattern-not: $SEL.html(marked.parse(...)) # Exclude clearing/resetting content: - pattern-not: $SEL.html('') - pattern-not: $SEL.html("") - pattern-not: $SEL.html(' ') - pattern-not: $SEL.html(" ") - # Exclude reading innerHTML (no arguments or getting value): + # Exclude reading innerHTML (no arguments): - pattern-not: $SEL.html() paths: include: From c81e4c4095adae93022f77c2857e5122b1456211 Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:50:57 -0500 Subject: [PATCH 5/9] Manual exclusion of known unproblematic --- .semgrep/xss-prevention.yml | 6 +++++- modules/ui/UiField.js | 2 +- modules/ui/UiRapidCatalog.js | 4 ++-- modules/ui/combobox.js | 2 +- modules/ui/fields/radio.js | 2 +- modules/ui/flash.js | 2 +- modules/ui/intro/UiCurtain.js | 2 +- modules/ui/maproulette_details.js | 4 ++-- modules/ui/maproulette_editor.js | 2 +- modules/ui/osmose_details.js | 6 +++--- modules/ui/panes/help.js | 2 +- modules/ui/preset_list.js | 4 ++-- modules/ui/sections/feature_type.js | 2 +- modules/ui/sections/raw_membership_editor.js | 2 +- modules/ui/sections/selection_list.js | 4 ++-- modules/ui/settings/custom_background.js | 2 +- modules/ui/settings/custom_data.js | 4 ++-- modules/ui/tag_reference.js | 2 +- modules/ui/tooltip.js | 2 +- 19 files changed, 30 insertions(+), 26 deletions(-) diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml index daf43a8a61..5f571695e9 100644 --- a/.semgrep/xss-prevention.yml +++ b/.semgrep/xss-prevention.yml @@ -5,6 +5,7 @@ rules: Potential XSS: .html() called with unsanitized content. Use utilSanitizeHTML() for external/user data, .text() for plain text, or l10n.t()/l10n.tHtml() for trusted localization strings. + If this is a false positive, add: // nosemgrep: d3-unsanitized-html severity: WARNING patterns: - pattern: $SEL.html($CONTENT) @@ -13,8 +14,11 @@ rules: # Exclude safe patterns - localization (trusted developer content): - pattern-not: $SEL.html(l10n.tHtml(...)) - pattern-not: $SEL.html(l10n.t(...)) - # Exclude safe patterns - marked.parse on localization content: + - pattern-not: $SEL.html(uifield.tHtml(...)) + # Exclude safe patterns - marked.parse (typically on trusted content): - pattern-not: $SEL.html(marked.parse(...)) + # Exclude safe patterns - ternary with l10n: + - pattern-not: $SEL.html($COND ? l10n.tHtml(...) : l10n.tHtml(...)) # Exclude clearing/resetting content: - pattern-not: $SEL.html('') - pattern-not: $SEL.html("") diff --git a/modules/ui/UiField.js b/modules/ui/UiField.js index 276f708792..b27aac63ad 100644 --- a/modules/ui/UiField.js +++ b/modules/ui/UiField.js @@ -232,7 +232,7 @@ export class UiField { textEnter .append('span') .attr('class', 'label-textvalue') - .html(this.label); + .html(this.label); // nosemgrep: d3-unsanitized-html - preset field labels are trusted textEnter .append('span') diff --git a/modules/ui/UiRapidCatalog.js b/modules/ui/UiRapidCatalog.js index 06427ace0a..599b551465 100644 --- a/modules/ui/UiRapidCatalog.js +++ b/modules/ui/UiRapidCatalog.js @@ -472,7 +472,7 @@ export class UiRapidCatalog extends EventEmitter { .classed('hide', d => d.filtered); $datasets.selectAll('.rapid-catalog-dataset-name') - .html(d => this.highlight(this._filterText, d.getLabel())); + .html(d => this.highlight(this._filterText, d.getLabel())); // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally $datasets.selectAll('.rapid-catalog-dataset-link-text') .text(l10n.t('rapid_menu.more_info')); @@ -489,7 +489,7 @@ export class UiRapidCatalog extends EventEmitter { .attr('title', l10n.t('rapid_poweruser.beta')); // alt text $datasets.selectAll('.rapid-catalog-dataset-snippet') - .html(d => this.highlight(this._filterText, d.getDescription())); + .html(d => this.highlight(this._filterText, d.getDescription())); // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally $datasets.selectAll('.dataset-added-text') .text(d => d.added ? '\u2705 ' + l10n.t('rapid_menu.dataset_added') : ''); // 2705 = emoji check diff --git a/modules/ui/combobox.js b/modules/ui/combobox.js index c24f2fe9c4..71ebb4f39e 100644 --- a/modules/ui/combobox.js +++ b/modules/ui/combobox.js @@ -390,7 +390,7 @@ export function uiCombobox(context, klass) { if (typeof d.display === 'function') { // display function selection.call(d.display); } else if (d.display) { // display html value - selection.html(d.display); + selection.html(d.display); // nosemgrep: d3-unsanitized-html - display values from internal presets } else { // text value selection.text(d.value); } diff --git a/modules/ui/fields/radio.js b/modules/ui/fields/radio.js index 5cff930249..a1cd54ed0a 100644 --- a/modules/ui/fields/radio.js +++ b/modules/ui/fields/radio.js @@ -299,7 +299,7 @@ export function uiFieldRadio(context, uifield) { if (selection.empty()) { placeholder.html(l10n.tHtml('inspector.none')); } else { - placeholder.html(selection.attr('value')); + placeholder.html(selection.attr('value')); // nosemgrep: d3-unsanitized-html - value from radio button set by l10n _oldType[selection.datum()] = tags[selection.datum()]; } diff --git a/modules/ui/flash.js b/modules/ui/flash.js index 9968311237..e311b4f83d 100644 --- a/modules/ui/flash.js +++ b/modules/ui/flash.js @@ -64,7 +64,7 @@ export function uiFlash(context) { content .selectAll('.flash-text') .attr('class', 'flash-text') - .html(_label); + .html(_label); // nosemgrep: d3-unsanitized-html - flash labels from internal UI code _flashTimer = d3_timeout(function() { diff --git a/modules/ui/intro/UiCurtain.js b/modules/ui/intro/UiCurtain.js index 9b79f5175d..0d6556f9bb 100644 --- a/modules/ui/intro/UiCurtain.js +++ b/modules/ui/intro/UiCurtain.js @@ -398,7 +398,7 @@ export class UiCurtain { this.$tooltip .attr('class', klass) .selectAll('.popover-inner') - .html(html); + .html(html); // nosemgrep: d3-unsanitized-html - intro tutorial content from internal code if (opts.buttonText && opts.buttonCallback) { this.$tooltip.selectAll('button.action') diff --git a/modules/ui/maproulette_details.js b/modules/ui/maproulette_details.js index 1ff00dbb72..7fa4493e95 100644 --- a/modules/ui/maproulette_details.js +++ b/modules/ui/maproulette_details.js @@ -172,7 +172,7 @@ export function uiMapRouletteDetails(context) { .append('section') .attr('class', 'qa-details-container'); descContent - .html(descriptionHtml) + .html(descriptionHtml) // nosemgrep: d3-unsanitized-html - already sanitized above .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -191,7 +191,7 @@ export function uiMapRouletteDetails(context) { .append('article') .attr('class', 'qa-details-container'); instructionContent - .html(instructionHtml) + .html(instructionHtml) // nosemgrep: d3-unsanitized-html - already sanitized above .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); diff --git a/modules/ui/maproulette_editor.js b/modules/ui/maproulette_editor.js index 6751643424..d2110d565a 100644 --- a/modules/ui/maproulette_editor.js +++ b/modules/ui/maproulette_editor.js @@ -157,7 +157,7 @@ export function uiMapRouletteEditor(context) { commentSave = commentSaveEnter.merge(commentSave); commentSave.select('.note-save-header') // Corrected class name - .html(l10n.t('map_data.layers.maproulette.comment') + + .html(l10n.t('map_data.layers.maproulette.comment') + // nosemgrep: d3-unsanitized-html - l10n + internal action string ' ' + _actionTaken + '' ); diff --git a/modules/ui/osmose_details.js b/modules/ui/osmose_details.js index 5dd20fac39..703ef47fe9 100644 --- a/modules/ui/osmose_details.js +++ b/modules/ui/osmose_details.js @@ -50,7 +50,7 @@ export function uiOsmoseDetails(context) { div .append('p') .attr('class', 'qa-details-description-text') - .html(d => issueString(d, 'detail')) + .html(d => issueString(d, 'detail')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -77,7 +77,7 @@ export function uiOsmoseDetails(context) { div .append('p') - .html(d => issueString(d, 'fix')) + .html(d => issueString(d, 'fix')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -95,7 +95,7 @@ export function uiOsmoseDetails(context) { div .append('p') - .html(d => issueString(d, 'trap')) + .html(d => issueString(d, 'trap')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); diff --git a/modules/ui/panes/help.js b/modules/ui/panes/help.js index 08eff9319d..0607530d5b 100644 --- a/modules/ui/panes/help.js +++ b/modules/ui/panes/help.js @@ -391,7 +391,7 @@ export function uiPaneHelp(context) { helpPane.selectAll('.pane-heading > h2').text(d.title); const content = _selection.selectAll('.help-content'); - content.html(d.contentHtml); + content.html(d.contentHtml); // nosemgrep: d3-unsanitized-html - help content from internal docs content.selectAll('a').attr('target', '_blank'); // outbound links should open in new tab _selection.selectAll('.toc > li') diff --git a/modules/ui/preset_list.js b/modules/ui/preset_list.js index 2cbab9b5a1..c2236ee550 100644 --- a/modules/ui/preset_list.js +++ b/modules/ui/preset_list.js @@ -360,7 +360,7 @@ export function uiPresetList(context) { .attr('class', 'namepart') .call(uiIcon((isRTL ? '#rapid-icon-backward' : '#rapid-icon-forward'), 'inline')) .append('span') - .html(() => preset.nameLabel() + '…'); + .html(() => preset.nameLabel() + '…'); // nosemgrep: d3-unsanitized-html - preset names are trusted this.box = selection .append('div') @@ -481,7 +481,7 @@ export function uiPresetList(context) { .enter() .append('div') .attr('class', 'namepart') - .html(d => d); + .html(d => d); // nosemgrep: d3-unsanitized-html - preset name parts are trusted wrapEnter.call(this.reference.button); selection.call(this.reference.body); diff --git a/modules/ui/sections/feature_type.js b/modules/ui/sections/feature_type.js index 4c1b6c7694..e0ed7bee9f 100644 --- a/modules/ui/sections/feature_type.js +++ b/modules/ui/sections/feature_type.js @@ -106,7 +106,7 @@ export function uiSectionFeatureType(context) { .enter() .append('div') .attr('class', 'namepart') - .html(d => d); + .html(d => d); // nosemgrep: d3-unsanitized-html - preset names from internal system } section.entityIDs = function(val) { diff --git a/modules/ui/sections/raw_membership_editor.js b/modules/ui/sections/raw_membership_editor.js index f11a077f3f..2101a9ac0b 100644 --- a/modules/ui/sections/raw_membership_editor.js +++ b/modules/ui/sections/raw_membership_editor.js @@ -365,7 +365,7 @@ export function uiSectionRawMembershipEditor(context) { labelLink .append('span') .attr('class', 'member-entity-type') - .html(function(d) { + .html(function(d) { // nosemgrep: d3-unsanitized-html - preset names are trusted const matched = presets.match(d.relation, editor.staging.graph); return (matched && matched.name()) || l10n.t('inspector.relation'); }); diff --git a/modules/ui/sections/selection_list.js b/modules/ui/sections/selection_list.js index ff4239140d..b7cea8d09f 100644 --- a/modules/ui/sections/selection_list.js +++ b/modules/ui/sections/selection_list.js @@ -114,10 +114,10 @@ export function uiSectionSelectionList(context) { }); items.selectAll('.entity-type') - .html(entity => presets.match(entity, graph).name()); + .html(entity => presets.match(entity, graph).name()); // nosemgrep: d3-unsanitized-html - preset names are trusted items.selectAll('.entity-name') - .html(d => { + .html(d => { // nosemgrep: d3-unsanitized-html - l10n.displayName returns trusted content const entity = graph.entity(d.id); return l10n.displayName(entity.tags); }); diff --git a/modules/ui/settings/custom_background.js b/modules/ui/settings/custom_background.js index 83b56609dd..71a1cf985e 100644 --- a/modules/ui/settings/custom_background.js +++ b/modules/ui/settings/custom_background.js @@ -69,7 +69,7 @@ ${info} textSection .append('div') .attr('class', 'instructions-template') - .html(instructions); + .html(instructions); // nosemgrep: d3-unsanitized-html - instructions built from l10n strings textSection .append('textarea') diff --git a/modules/ui/settings/custom_data.js b/modules/ui/settings/custom_data.js index d3b4082963..4983c07cd1 100644 --- a/modules/ui/settings/custom_data.js +++ b/modules/ui/settings/custom_data.js @@ -61,7 +61,7 @@ ${file_tip} textSection .append('div') .attr('class', 'instructions-template') - .html(fileHtml); + .html(fileHtml); // nosemgrep: d3-unsanitized-html - fileHtml built from l10n strings textSection .append('input') @@ -109,7 +109,7 @@ ${url_tokens} textSection .append('div') .attr('class', 'instructions-template') - .html(urlHtml); + .html(urlHtml); // nosemgrep: d3-unsanitized-html - urlHtml built from l10n strings textSection .append('textarea') diff --git a/modules/ui/tag_reference.js b/modules/ui/tag_reference.js index b7862ac34c..667c19e015 100644 --- a/modules/ui/tag_reference.js +++ b/modules/ui/tag_reference.js @@ -70,7 +70,7 @@ export function uiTagReference(context, what) { _body .append('p') .attr('class', 'tag-reference-description') - .html(docsHtml) + .html(docsHtml) // nosemgrep: d3-unsanitized-html - docsHtml from l10n.htmlForLocalizedText or l10n.tHtml .append('a') .attr('class', 'tag-reference-edit') .attr('target', '_blank') diff --git a/modules/ui/tooltip.js b/modules/ui/tooltip.js index f25ba8d994..2b808ba473 100644 --- a/modules/ui/tooltip.js +++ b/modules/ui/tooltip.js @@ -62,7 +62,7 @@ export function uiTooltip(context) { .append('div') .attr('class', 'tooltip-text') .merge(textWrap) - .html(d => d); // watch out: a few tooltips still send html through here + .html(d => d); // nosemgrep: d3-unsanitized-html - tooltip content from internal UI code const shortcutWrap = selection .selectAll('.tooltip-keyhint') From 1815b57a290cdc7370ac63e1bd41e22a82c3ea1d Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 13:52:50 -0500 Subject: [PATCH 6/9] Remove problematic rule --- .semgrep/xss-prevention.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml index 5f571695e9..f36b9aee7b 100644 --- a/.semgrep/xss-prevention.yml +++ b/.semgrep/xss-prevention.yml @@ -17,8 +17,6 @@ rules: - pattern-not: $SEL.html(uifield.tHtml(...)) # Exclude safe patterns - marked.parse (typically on trusted content): - pattern-not: $SEL.html(marked.parse(...)) - # Exclude safe patterns - ternary with l10n: - - pattern-not: $SEL.html($COND ? l10n.tHtml(...) : l10n.tHtml(...)) # Exclude clearing/resetting content: - pattern-not: $SEL.html('') - pattern-not: $SEL.html("") From d2513eeaf26716c5baf894e598328cf879179f10 Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 14:12:22 -0500 Subject: [PATCH 7/9] Add remaining exclusions --- modules/ui/UiField.js | 3 ++- modules/ui/UiRapidCatalog.js | 6 ++++-- modules/ui/combobox.js | 3 ++- modules/ui/commit_warnings.js | 1 + modules/ui/conflicts.js | 2 ++ modules/ui/fields/access.js | 1 + modules/ui/fields/check.js | 2 ++ modules/ui/fields/cycleway.js | 1 + modules/ui/fields/radio.js | 4 +++- modules/ui/fields/wikidata.js | 1 + modules/ui/flash.js | 3 ++- modules/ui/intro/UiCurtain.js | 3 ++- modules/ui/keepRight_details.js | 1 + modules/ui/maproulette_details.js | 6 ++++-- modules/ui/maproulette_editor.js | 3 ++- modules/ui/note_comments.js | 2 ++ modules/ui/osmose_details.js | 11 ++++++++--- modules/ui/panes/help.js | 3 ++- modules/ui/preset_list.js | 6 ++++-- modules/ui/sections/feature_type.js | 3 ++- modules/ui/sections/raw_member_editor.js | 1 + modules/ui/sections/raw_membership_editor.js | 3 ++- modules/ui/sections/selection_list.js | 6 ++++-- modules/ui/sections/validation_rules.js | 1 + modules/ui/settings/custom_background.js | 3 ++- modules/ui/settings/custom_data.js | 6 ++++-- modules/ui/tag_reference.js | 3 ++- modules/ui/tooltip.js | 3 ++- 28 files changed, 66 insertions(+), 25 deletions(-) diff --git a/modules/ui/UiField.js b/modules/ui/UiField.js index b27aac63ad..27a79bb6c9 100644 --- a/modules/ui/UiField.js +++ b/modules/ui/UiField.js @@ -229,10 +229,11 @@ export class UiField { .append('span') .attr('class', 'label-text'); + // nosemgrep: d3-unsanitized-html - preset field labels are trusted textEnter .append('span') .attr('class', 'label-textvalue') - .html(this.label); // nosemgrep: d3-unsanitized-html - preset field labels are trusted + .html(this.label); textEnter .append('span') diff --git a/modules/ui/UiRapidCatalog.js b/modules/ui/UiRapidCatalog.js index 599b551465..9ea31d9aeb 100644 --- a/modules/ui/UiRapidCatalog.js +++ b/modules/ui/UiRapidCatalog.js @@ -471,8 +471,9 @@ export class UiRapidCatalog extends EventEmitter { .classed('added', d => d.added) .classed('hide', d => d.filtered); + // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally $datasets.selectAll('.rapid-catalog-dataset-name') - .html(d => this.highlight(this._filterText, d.getLabel())); // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally + .html(d => this.highlight(this._filterText, d.getLabel())); $datasets.selectAll('.rapid-catalog-dataset-link-text') .text(l10n.t('rapid_menu.more_info')); @@ -488,8 +489,9 @@ export class UiRapidCatalog extends EventEmitter { $datasets.selectAll('.dataset-category-preview') .attr('title', l10n.t('rapid_poweruser.beta')); // alt text + // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally $datasets.selectAll('.rapid-catalog-dataset-snippet') - .html(d => this.highlight(this._filterText, d.getDescription())); // nosemgrep: d3-unsanitized-html - highlight() sanitizes internally + .html(d => this.highlight(this._filterText, d.getDescription())); $datasets.selectAll('.dataset-added-text') .text(d => d.added ? '\u2705 ' + l10n.t('rapid_menu.dataset_added') : ''); // 2705 = emoji check diff --git a/modules/ui/combobox.js b/modules/ui/combobox.js index 71ebb4f39e..420f6c1411 100644 --- a/modules/ui/combobox.js +++ b/modules/ui/combobox.js @@ -390,7 +390,8 @@ export function uiCombobox(context, klass) { if (typeof d.display === 'function') { // display function selection.call(d.display); } else if (d.display) { // display html value - selection.html(d.display); // nosemgrep: d3-unsanitized-html - display values from internal presets + // nosemgrep: d3-unsanitized-html - display values from internal presets + selection.html(d.display); } else { // text value selection.text(d.value); } diff --git a/modules/ui/commit_warnings.js b/modules/ui/commit_warnings.js index db61e7a42e..83a6286c4d 100644 --- a/modules/ui/commit_warnings.js +++ b/modules/ui/commit_warnings.js @@ -30,6 +30,7 @@ export function uiCommitWarnings(context) { .append('div') .attr('class', 'modal-section ' + section + ' fillL2'); + // nosemgrep: d3-unsanitized-html - ternary with l10n.tHtml containerEnter .append('h3') .html(severity === 'warning' ? l10n.tHtml('commit.warnings') : l10n.tHtml('commit.errors')); diff --git a/modules/ui/conflicts.js b/modules/ui/conflicts.js index 29160975dc..1d0017dbc1 100644 --- a/modules/ui/conflicts.js +++ b/modules/ui/conflicts.js @@ -175,6 +175,7 @@ export function uiConflicts(context) { .append('div') .attr('class', 'conflict-detail-container'); + // nosemgrep: d3-unsanitized-html - uses utilSanitizeHTML details .append('ul') .attr('class', 'conflict-detail-list') @@ -190,6 +191,7 @@ export function uiConflicts(context) { .attr('class', 'conflict-choices') .call(addChoices); + // nosemgrep: d3-unsanitized-html - l10n.tHtml returns trusted l10n content details .append('div') .attr('class', 'conflict-nav-buttons joined cf') diff --git a/modules/ui/fields/access.js b/modules/ui/fields/access.js index c941f8c1eb..234ebc14da 100644 --- a/modules/ui/fields/access.js +++ b/modules/ui/fields/access.js @@ -37,6 +37,7 @@ export function uiFieldAccess(context, uifield) { .append('li') .attr('class', d => `labeled-input preset-access-${d}`); + // nosemgrep: d3-unsanitized-html - uifield.tHtml returns trusted l10n content enter .append('div') .attr('class', 'label preset-label-access') diff --git a/modules/ui/fields/check.js b/modules/ui/fields/check.js index 60501f9715..14a5dbd692 100644 --- a/modules/ui/fields/check.js +++ b/modules/ui/fields/check.js @@ -106,6 +106,7 @@ export function uiFieldCheck(context, uifield) { .attr('type', 'checkbox') .attr('id', uifield.uid); + // nosemgrep: d3-unsanitized-html - texts from internal field options enter .append('span') .html(texts[0]) @@ -212,6 +213,7 @@ export function uiFieldCheck(context, uifield) { .property('indeterminate', isMixed || (uifield.type !== 'defaultCheck' && !_value)) .property('checked', isChecked(_value)); + // nosemgrep: d3-unsanitized-html - textFor returns l10n content text .html(isMixed ? l10n.tHtml('inspector.multiple_values') : textFor(_value)) .classed('mixed', isMixed); diff --git a/modules/ui/fields/cycleway.js b/modules/ui/fields/cycleway.js index c5f580a345..165b1e49b8 100644 --- a/modules/ui/fields/cycleway.js +++ b/modules/ui/fields/cycleway.js @@ -46,6 +46,7 @@ export function uiFieldCycleway(context, uifield) { .append('li') .attr('class', function(d) { return 'labeled-input preset-cycleway-' + stripcolon(d); }); + // nosemgrep: d3-unsanitized-html - uifield.tHtml returns trusted l10n content enter .append('div') .attr('class', 'label preset-label-cycleway') diff --git a/modules/ui/fields/radio.js b/modules/ui/fields/radio.js index a1cd54ed0a..8c42364131 100644 --- a/modules/ui/fields/radio.js +++ b/modules/ui/fields/radio.js @@ -62,6 +62,7 @@ export function uiFieldRadio(context, uifield) { .attr('value', function(d) { return uifield.t(`options.${d}`, { 'default': d }); }) .attr('checked', false); + // nosemgrep: d3-unsanitized-html - uifield.tHtml returns trusted l10n content enter .append('span') .html(function(d) { return uifield.tHtml(`options.${d}`, { 'default': d }); }); @@ -299,7 +300,8 @@ export function uiFieldRadio(context, uifield) { if (selection.empty()) { placeholder.html(l10n.tHtml('inspector.none')); } else { - placeholder.html(selection.attr('value')); // nosemgrep: d3-unsanitized-html - value from radio button set by l10n + // nosemgrep: d3-unsanitized-html - value from radio button set by l10n + placeholder.html(selection.attr('value')); _oldType[selection.datum()] = tags[selection.datum()]; } diff --git a/modules/ui/fields/wikidata.js b/modules/ui/fields/wikidata.js index 5bc846d486..98abf8ccb2 100644 --- a/modules/ui/fields/wikidata.js +++ b/modules/ui/fields/wikidata.js @@ -105,6 +105,7 @@ export function uiFieldWikidata(context, uifield) { .append('li') .attr('class', function(d) { return 'labeled-input preset-wikidata-' + d; }); + // nosemgrep: d3-unsanitized-html - l10n.tHtml returns trusted l10n content enter .append('div') .attr('class', 'label') diff --git a/modules/ui/flash.js b/modules/ui/flash.js index e311b4f83d..e21f32c76c 100644 --- a/modules/ui/flash.js +++ b/modules/ui/flash.js @@ -61,10 +61,11 @@ export function uiFlash(context) { .selectAll('.flash-icon use') .attr('xlink:href', _iconName); + // nosemgrep: d3-unsanitized-html - flash labels from internal UI code content .selectAll('.flash-text') .attr('class', 'flash-text') - .html(_label); // nosemgrep: d3-unsanitized-html - flash labels from internal UI code + .html(_label); _flashTimer = d3_timeout(function() { diff --git a/modules/ui/intro/UiCurtain.js b/modules/ui/intro/UiCurtain.js index 0d6556f9bb..8992c08c86 100644 --- a/modules/ui/intro/UiCurtain.js +++ b/modules/ui/intro/UiCurtain.js @@ -395,10 +395,11 @@ export class UiCurtain { html += `
`; } + // nosemgrep: d3-unsanitized-html - intro tutorial content from internal code this.$tooltip .attr('class', klass) .selectAll('.popover-inner') - .html(html); // nosemgrep: d3-unsanitized-html - intro tutorial content from internal code + .html(html); if (opts.buttonText && opts.buttonCallback) { this.$tooltip.selectAll('button.action') diff --git a/modules/ui/keepRight_details.js b/modules/ui/keepRight_details.js index 635f26c49b..ad094408f4 100644 --- a/modules/ui/keepRight_details.js +++ b/modules/ui/keepRight_details.js @@ -49,6 +49,7 @@ export function uiKeepRightDetails(context) { .append('h4') .text(l10n.t('QA.keepRight.detail_description')); + // nosemgrep: d3-unsanitized-html - uses utilSanitizeHTML descriptionEnter .append('div') .attr('class', 'qa-details-description-text') diff --git a/modules/ui/maproulette_details.js b/modules/ui/maproulette_details.js index 7fa4493e95..24758fec1e 100644 --- a/modules/ui/maproulette_details.js +++ b/modules/ui/maproulette_details.js @@ -171,8 +171,9 @@ export function uiMapRouletteDetails(context) { const descContent = descSection .append('section') .attr('class', 'qa-details-container'); + // nosemgrep: d3-unsanitized-html - already sanitized above descContent - .html(descriptionHtml) // nosemgrep: d3-unsanitized-html - already sanitized above + .html(descriptionHtml) .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -190,8 +191,9 @@ export function uiMapRouletteDetails(context) { const instructionContent = instructionSection .append('article') .attr('class', 'qa-details-container'); + // nosemgrep: d3-unsanitized-html - already sanitized above instructionContent - .html(instructionHtml) // nosemgrep: d3-unsanitized-html - already sanitized above + .html(instructionHtml) .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); diff --git a/modules/ui/maproulette_editor.js b/modules/ui/maproulette_editor.js index d2110d565a..7360f10690 100644 --- a/modules/ui/maproulette_editor.js +++ b/modules/ui/maproulette_editor.js @@ -156,8 +156,9 @@ export function uiMapRouletteEditor(context) { // update commentSave = commentSaveEnter.merge(commentSave); + // nosemgrep: d3-unsanitized-html - l10n + internal action string commentSave.select('.note-save-header') // Corrected class name - .html(l10n.t('map_data.layers.maproulette.comment') + // nosemgrep: d3-unsanitized-html - l10n + internal action string + .html(l10n.t('map_data.layers.maproulette.comment') + ' ' + _actionTaken + '' ); diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index 0ca5dd7934..ec9be3788e 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -59,11 +59,13 @@ export function uiNoteComments(context) { } }); + // nosemgrep: d3-unsanitized-html - l10n.t with trusted date formatting metadataEnter .append('div') .attr('class', 'comment-date') .html(d => l10n.t(`note.status.${d.action}`, { when: localeDateString(d.date) })); + // NOTE: This is the XSS test case - do NOT add nosemgrep here mainEnter .append('div') .attr('class', 'comment-text') diff --git a/modules/ui/osmose_details.js b/modules/ui/osmose_details.js index 703ef47fe9..c6a062c7d0 100644 --- a/modules/ui/osmose_details.js +++ b/modules/ui/osmose_details.js @@ -47,10 +47,11 @@ export function uiOsmoseDetails(context) { .append('h4') .text(l10n.t('QA.keepRight.detail_description')); + // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService div .append('p') .attr('class', 'qa-details-description-text') - .html(d => issueString(d, 'detail')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService + .html(d => issueString(d, 'detail')) .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -75,9 +76,10 @@ export function uiOsmoseDetails(context) { .append('h4') .text(l10n.t('QA.osmose.fix_title')); + // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService div .append('p') - .html(d => issueString(d, 'fix')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService + .html(d => issueString(d, 'fix')) .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -93,9 +95,10 @@ export function uiOsmoseDetails(context) { .append('h4') .text(l10n.t('QA.osmose.trap_title')); + // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService div .append('p') - .html(d => issueString(d, 'trap')) // nosemgrep: d3-unsanitized-html - sanitized in OsmoseService + .html(d => issueString(d, 'trap')) .selectAll('a') .attr('rel', 'noopener') .attr('target', '_blank'); @@ -117,6 +120,7 @@ export function uiOsmoseDetails(context) { .append('h4') .text(l10n.t('QA.osmose.detail_title')); + // nosemgrep: d3-unsanitized-html - d.detail already sanitized in OsmoseService detailsDiv .append('p') .html(d => d.detail) @@ -130,6 +134,7 @@ export function uiOsmoseDetails(context) { .append('h4') .text(l10n.t('QA.osmose.elems_title')); + // nosemgrep: d3-unsanitized-html - element IDs from OSM data elemsDiv .append('ul').selectAll('li') .data(d.elems) diff --git a/modules/ui/panes/help.js b/modules/ui/panes/help.js index 0607530d5b..7f0d01620a 100644 --- a/modules/ui/panes/help.js +++ b/modules/ui/panes/help.js @@ -391,7 +391,8 @@ export function uiPaneHelp(context) { helpPane.selectAll('.pane-heading > h2').text(d.title); const content = _selection.selectAll('.help-content'); - content.html(d.contentHtml); // nosemgrep: d3-unsanitized-html - help content from internal docs + // nosemgrep: d3-unsanitized-html - help content from internal docs + content.html(d.contentHtml); content.selectAll('a').attr('target', '_blank'); // outbound links should open in new tab _selection.selectAll('.toc > li') diff --git a/modules/ui/preset_list.js b/modules/ui/preset_list.js index c2236ee550..4031b95db5 100644 --- a/modules/ui/preset_list.js +++ b/modules/ui/preset_list.js @@ -355,12 +355,13 @@ export function uiPresetList(context) { .append('div') .attr('class', 'label-inner'); + // nosemgrep: d3-unsanitized-html - preset names are trusted labelEnter .append('div') .attr('class', 'namepart') .call(uiIcon((isRTL ? '#rapid-icon-backward' : '#rapid-icon-forward'), 'inline')) .append('span') - .html(() => preset.nameLabel() + '…'); // nosemgrep: d3-unsanitized-html - preset names are trusted + .html(() => preset.nameLabel() + '…'); this.box = selection .append('div') @@ -476,12 +477,13 @@ export function uiPresetList(context) { preset.subtitleLabel() ].filter(Boolean); + // nosemgrep: d3-unsanitized-html - preset name parts are trusted labelEnter.selectAll('.namepart') .data(nameparts) .enter() .append('div') .attr('class', 'namepart') - .html(d => d); // nosemgrep: d3-unsanitized-html - preset name parts are trusted + .html(d => d); wrapEnter.call(this.reference.button); selection.call(this.reference.body); diff --git a/modules/ui/sections/feature_type.js b/modules/ui/sections/feature_type.js index e0ed7bee9f..c1ba545770 100644 --- a/modules/ui/sections/feature_type.js +++ b/modules/ui/sections/feature_type.js @@ -102,11 +102,12 @@ export function uiSectionFeatureType(context) { nameparts.exit() .remove(); + // nosemgrep: d3-unsanitized-html - preset names from internal system nameparts .enter() .append('div') .attr('class', 'namepart') - .html(d => d); // nosemgrep: d3-unsanitized-html - preset names from internal system + .html(d => d); } section.entityIDs = function(val) { diff --git a/modules/ui/sections/raw_member_editor.js b/modules/ui/sections/raw_member_editor.js index 239e39392a..67f18d158b 100644 --- a/modules/ui/sections/raw_member_editor.js +++ b/modules/ui/sections/raw_member_editor.js @@ -184,6 +184,7 @@ export function uiSectionRawMemberEditor(context) { .attr('href', '#') .on('click', selectMember); + // nosemgrep: d3-unsanitized-html - preset names are trusted labelLink .append('span') .attr('class', 'member-entity-type') diff --git a/modules/ui/sections/raw_membership_editor.js b/modules/ui/sections/raw_membership_editor.js index 2101a9ac0b..a1b49ec096 100644 --- a/modules/ui/sections/raw_membership_editor.js +++ b/modules/ui/sections/raw_membership_editor.js @@ -362,10 +362,11 @@ export function uiSectionRawMembershipEditor(context) { .attr('href', '#') .on('click', selectRelation); + // nosemgrep: d3-unsanitized-html - preset names are trusted labelLink .append('span') .attr('class', 'member-entity-type') - .html(function(d) { // nosemgrep: d3-unsanitized-html - preset names are trusted + .html(function(d) { const matched = presets.match(d.relation, editor.staging.graph); return (matched && matched.name()) || l10n.t('inspector.relation'); }); diff --git a/modules/ui/sections/selection_list.js b/modules/ui/sections/selection_list.js index b7cea8d09f..3303f8ccca 100644 --- a/modules/ui/sections/selection_list.js +++ b/modules/ui/sections/selection_list.js @@ -113,11 +113,13 @@ export function uiSectionSelectionList(context) { return '#rapid-icon-' + entity.geometry(graph); }); + // nosemgrep: d3-unsanitized-html - preset names are trusted items.selectAll('.entity-type') - .html(entity => presets.match(entity, graph).name()); // nosemgrep: d3-unsanitized-html - preset names are trusted + .html(entity => presets.match(entity, graph).name()); + // nosemgrep: d3-unsanitized-html - l10n.displayName returns trusted content items.selectAll('.entity-name') - .html(d => { // nosemgrep: d3-unsanitized-html - l10n.displayName returns trusted content + .html(d => { const entity = graph.entity(d.id); return l10n.displayName(entity.tags); }); diff --git a/modules/ui/sections/validation_rules.js b/modules/ui/sections/validation_rules.js index 9756943cc9..a6b3a3b3c3 100644 --- a/modules/ui/sections/validation_rules.js +++ b/modules/ui/sections/validation_rules.js @@ -96,6 +96,7 @@ export function uiSectionValidationRules(context) { .attr('name', 'rule') .on('change', toggleRule); + // nosemgrep: d3-unsanitized-html - l10n.tHtml with internal params label .append('span') .html(d => { diff --git a/modules/ui/settings/custom_background.js b/modules/ui/settings/custom_background.js index 71a1cf985e..8deda00aca 100644 --- a/modules/ui/settings/custom_background.js +++ b/modules/ui/settings/custom_background.js @@ -66,10 +66,11 @@ ${info} let textSection = modal.select('.modal-section.message-text'); + // nosemgrep: d3-unsanitized-html - instructions built from l10n strings textSection .append('div') .attr('class', 'instructions-template') - .html(instructions); // nosemgrep: d3-unsanitized-html - instructions built from l10n strings + .html(instructions); textSection .append('textarea') diff --git a/modules/ui/settings/custom_data.js b/modules/ui/settings/custom_data.js index 4983c07cd1..02d02c1a1a 100644 --- a/modules/ui/settings/custom_data.js +++ b/modules/ui/settings/custom_data.js @@ -58,10 +58,11 @@ ${file_instructions} ${file_tip} `); + // nosemgrep: d3-unsanitized-html - fileHtml built from l10n strings textSection .append('div') .attr('class', 'instructions-template') - .html(fileHtml); // nosemgrep: d3-unsanitized-html - fileHtml built from l10n strings + .html(fileHtml); textSection .append('input') @@ -106,10 +107,11 @@ ${url_tokens} * \`${url_example_pmtiles}\` `); + // nosemgrep: d3-unsanitized-html - urlHtml built from l10n strings textSection .append('div') .attr('class', 'instructions-template') - .html(urlHtml); // nosemgrep: d3-unsanitized-html - urlHtml built from l10n strings + .html(urlHtml); textSection .append('textarea') diff --git a/modules/ui/tag_reference.js b/modules/ui/tag_reference.js index 667c19e015..91104345de 100644 --- a/modules/ui/tag_reference.js +++ b/modules/ui/tag_reference.js @@ -67,10 +67,11 @@ export function uiTagReference(context, what) { docsHtml = l10n.tHtml('inspector.no_documentation_key'); } + // nosemgrep: d3-unsanitized-html - docsHtml from l10n.htmlForLocalizedText or l10n.tHtml _body .append('p') .attr('class', 'tag-reference-description') - .html(docsHtml) // nosemgrep: d3-unsanitized-html - docsHtml from l10n.htmlForLocalizedText or l10n.tHtml + .html(docsHtml) .append('a') .attr('class', 'tag-reference-edit') .attr('target', '_blank') diff --git a/modules/ui/tooltip.js b/modules/ui/tooltip.js index 2b808ba473..487f753519 100644 --- a/modules/ui/tooltip.js +++ b/modules/ui/tooltip.js @@ -58,11 +58,12 @@ export function uiTooltip(context) { textWrap.exit() .remove(); + // nosemgrep: d3-unsanitized-html - tooltip content from internal UI code textWrap.enter() .append('div') .attr('class', 'tooltip-text') .merge(textWrap) - .html(d => d); // nosemgrep: d3-unsanitized-html - tooltip content from internal UI code + .html(d => d); const shortcutWrap = selection .selectAll('.tooltip-keyhint') From 9e1266f0b23cac5b4b54a5752e9511a80bd315d6 Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 14:14:42 -0500 Subject: [PATCH 8/9] Revert XSS test --- modules/ui/note_comments.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index ec9be3788e..c5e1732284 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -65,11 +65,11 @@ export function uiNoteComments(context) { .attr('class', 'comment-date') .html(d => l10n.t(`note.status.${d.action}`, { when: localeDateString(d.date) })); - // NOTE: This is the XSS test case - do NOT add nosemgrep here + // NOTE: d.html comes from OSM API and must be sanitized mainEnter .append('div') .attr('class', 'comment-text') - .html(d => d.html) + .html(d => utilSanitizeHTML(d.html)) .selectAll('a') .attr('rel', 'noopener nofollow') .attr('target', '_blank'); From 30993471ee5f64300f9d3cf79b20b0f9ebe3873b Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 18 Dec 2025 14:37:56 -0500 Subject: [PATCH 9/9] Add inner function pattern --- .semgrep/xss-prevention.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml index f36b9aee7b..c530122d2d 100644 --- a/.semgrep/xss-prevention.yml +++ b/.semgrep/xss-prevention.yml @@ -11,6 +11,8 @@ rules: - pattern: $SEL.html($CONTENT) # Exclude safe patterns - sanitized content: - pattern-not: $SEL.html(utilSanitizeHTML(...)) + - pattern-not: $SEL.html($X => utilSanitizeHTML(...)) + - pattern-not: $SEL.html(function($X) { return utilSanitizeHTML(...); }) # Exclude safe patterns - localization (trusted developer content): - pattern-not: $SEL.html(l10n.tHtml(...)) - pattern-not: $SEL.html(l10n.t(...))