diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 108ccde170..c0dd35b448 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 --error --config .semgrep/ diff --git a/.semgrep/xss-prevention.yml b/.semgrep/xss-prevention.yml new file mode 100644 index 0000000000..c530122d2d --- /dev/null +++ b/.semgrep/xss-prevention.yml @@ -0,0 +1,31 @@ +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.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) + # 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(...)) + - pattern-not: $SEL.html(uifield.tHtml(...)) + # Exclude safe patterns - marked.parse (typically on trusted 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): + - pattern-not: $SEL.html() + paths: + include: + - modules/ diff --git a/modules/ui/UiField.js b/modules/ui/UiField.js index 276f708792..27a79bb6c9 100644 --- a/modules/ui/UiField.js +++ b/modules/ui/UiField.js @@ -229,6 +229,7 @@ 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') diff --git a/modules/ui/UiRapidCatalog.js b/modules/ui/UiRapidCatalog.js index 06427ace0a..9ea31d9aeb 100644 --- a/modules/ui/UiRapidCatalog.js +++ b/modules/ui/UiRapidCatalog.js @@ -471,6 +471,7 @@ 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())); @@ -488,6 +489,7 @@ 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())); diff --git a/modules/ui/combobox.js b/modules/ui/combobox.js index c24f2fe9c4..420f6c1411 100644 --- a/modules/ui/combobox.js +++ b/modules/ui/combobox.js @@ -390,6 +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 + // 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 5cff930249..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,6 +300,7 @@ export function uiFieldRadio(context, uifield) { if (selection.empty()) { placeholder.html(l10n.tHtml('inspector.none')); } else { + // 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 9968311237..e21f32c76c 100644 --- a/modules/ui/flash.js +++ b/modules/ui/flash.js @@ -61,6 +61,7 @@ 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') diff --git a/modules/ui/intro/UiCurtain.js b/modules/ui/intro/UiCurtain.js index 9b79f5175d..8992c08c86 100644 --- a/modules/ui/intro/UiCurtain.js +++ b/modules/ui/intro/UiCurtain.js @@ -395,6 +395,7 @@ export class UiCurtain { html += `
`; } + // nosemgrep: d3-unsanitized-html - intro tutorial content from internal code this.$tooltip .attr('class', klass) .selectAll('.popover-inner') 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 1ff00dbb72..24758fec1e 100644 --- a/modules/ui/maproulette_details.js +++ b/modules/ui/maproulette_details.js @@ -171,6 +171,7 @@ export function uiMapRouletteDetails(context) { const descContent = descSection .append('section') .attr('class', 'qa-details-container'); + // nosemgrep: d3-unsanitized-html - already sanitized above descContent .html(descriptionHtml) .selectAll('a') @@ -190,6 +191,7 @@ export function uiMapRouletteDetails(context) { const instructionContent = instructionSection .append('article') .attr('class', 'qa-details-container'); + // nosemgrep: d3-unsanitized-html - already sanitized above instructionContent .html(instructionHtml) .selectAll('a') diff --git a/modules/ui/maproulette_editor.js b/modules/ui/maproulette_editor.js index 6751643424..7360f10690 100644 --- a/modules/ui/maproulette_editor.js +++ b/modules/ui/maproulette_editor.js @@ -156,6 +156,7 @@ 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') + ' ' + _actionTaken + '' diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index f5fbd500b9..c5e1732284 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: d.html comes from OSM API and must be sanitized mainEnter .append('div') .attr('class', 'comment-text') diff --git a/modules/ui/osmose_details.js b/modules/ui/osmose_details.js index 5dd20fac39..c6a062c7d0 100644 --- a/modules/ui/osmose_details.js +++ b/modules/ui/osmose_details.js @@ -47,6 +47,7 @@ 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') @@ -75,6 +76,7 @@ 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')) @@ -93,6 +95,7 @@ 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')) @@ -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 08eff9319d..7f0d01620a 100644 --- a/modules/ui/panes/help.js +++ b/modules/ui/panes/help.js @@ -391,6 +391,7 @@ export function uiPaneHelp(context) { helpPane.selectAll('.pane-heading > h2').text(d.title); const content = _selection.selectAll('.help-content'); + // 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 diff --git a/modules/ui/preset_list.js b/modules/ui/preset_list.js index 2cbab9b5a1..4031b95db5 100644 --- a/modules/ui/preset_list.js +++ b/modules/ui/preset_list.js @@ -355,6 +355,7 @@ export function uiPresetList(context) { .append('div') .attr('class', 'label-inner'); + // nosemgrep: d3-unsanitized-html - preset names are trusted labelEnter .append('div') .attr('class', 'namepart') @@ -476,6 +477,7 @@ export function uiPresetList(context) { preset.subtitleLabel() ].filter(Boolean); + // nosemgrep: d3-unsanitized-html - preset name parts are trusted labelEnter.selectAll('.namepart') .data(nameparts) .enter() diff --git a/modules/ui/sections/feature_type.js b/modules/ui/sections/feature_type.js index 4c1b6c7694..c1ba545770 100644 --- a/modules/ui/sections/feature_type.js +++ b/modules/ui/sections/feature_type.js @@ -102,6 +102,7 @@ export function uiSectionFeatureType(context) { nameparts.exit() .remove(); + // nosemgrep: d3-unsanitized-html - preset names from internal system nameparts .enter() .append('div') 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 f11a077f3f..a1b49ec096 100644 --- a/modules/ui/sections/raw_membership_editor.js +++ b/modules/ui/sections/raw_membership_editor.js @@ -362,6 +362,7 @@ 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') diff --git a/modules/ui/sections/selection_list.js b/modules/ui/sections/selection_list.js index ff4239140d..3303f8ccca 100644 --- a/modules/ui/sections/selection_list.js +++ b/modules/ui/sections/selection_list.js @@ -113,9 +113,11 @@ 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 - l10n.displayName returns trusted content items.selectAll('.entity-name') .html(d => { const entity = graph.entity(d.id); 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 83b56609dd..8deda00aca 100644 --- a/modules/ui/settings/custom_background.js +++ b/modules/ui/settings/custom_background.js @@ -66,6 +66,7 @@ ${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') diff --git a/modules/ui/settings/custom_data.js b/modules/ui/settings/custom_data.js index d3b4082963..02d02c1a1a 100644 --- a/modules/ui/settings/custom_data.js +++ b/modules/ui/settings/custom_data.js @@ -58,6 +58,7 @@ ${file_instructions} ${file_tip} `); + // nosemgrep: d3-unsanitized-html - fileHtml built from l10n strings textSection .append('div') .attr('class', 'instructions-template') @@ -106,6 +107,7 @@ ${url_tokens} * \`${url_example_pmtiles}\` `); + // nosemgrep: d3-unsanitized-html - urlHtml built from l10n strings textSection .append('div') .attr('class', 'instructions-template') diff --git a/modules/ui/tag_reference.js b/modules/ui/tag_reference.js index b7862ac34c..91104345de 100644 --- a/modules/ui/tag_reference.js +++ b/modules/ui/tag_reference.js @@ -67,6 +67,7 @@ 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') diff --git a/modules/ui/tooltip.js b/modules/ui/tooltip.js index f25ba8d994..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); // watch out: a few tooltips still send html through here + .html(d => d); const shortcutWrap = selection .selectAll('.tooltip-keyhint')