Échapper les noms de dalles injectés dans l'interface (HTML et <script> inline)

This commit is contained in:
Antoine Jacquin
2026-09-18 21:15:26 +02:00
parent 93ac46393f
commit de8d84b94b
2 changed files with 34 additions and 13 deletions

View File

@ -27,6 +27,7 @@ Intégration:
"""
import hashlib
import html
import json
import logging
import re
@ -1189,11 +1190,23 @@ def _panel_selection(all_viz_keys):
return ordered_viz, default_viz
def _json_for_script(obj):
"""JSON sûr à embarquer dans un <script> inline.
json.dumps n'échappe pas « </ » : un nom de dalle contenant
« </script><script>… » sortirait du contexte JS (nom de dossier issu
d'un fichier renommé dans input/). La séquence est neutralisée par un
antislash devant la barre oblique, ce qui est identique pour le parseur
JSON.
"""
return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
ordered_viz, default_viz = _panel_selection(all_viz_keys)
tiles_json = json.dumps(tiles, ensure_ascii=False)
tiles_json = _json_for_script(tiles)
# Les assets sont référencés avec une version (hash de leur contenu) :
# une URL neuve à chaque évolution du CSS/JS force le rechargement chez
@ -1212,20 +1225,20 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
gen_keys = [v for v in _VIZ_FALLBACK_ORDER if v in gen_base]
gen_keys += sorted(k for k in gen_base if k not in gen_keys)
gen_viz_options = "\n".join(
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
f'{VIZ_LABELS.get(v, v)}</option>'
f' <option value="{html.escape(KEYWORD_TO_STEP.get(v, v))}" selected>'
f'{html.escape(VIZ_LABELS.get(v, v))}</option>'
for v in gen_keys)
viz_meta_json = json.dumps({
viz_meta_json = _json_for_script({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
}, ensure_ascii=False)
})
stats_json = json.dumps(
stats_json = _json_for_script(
{'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys),
'n_tiles': len(tiles)},
ensure_ascii=False)
)
n_tiles = len(tiles)
grid_w = global_bbox['max_col'] - global_bbox['min_col'] + 1
@ -2320,7 +2333,7 @@ function renderLayerPanel() {
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + meta.label + '</span>' +
'<span class="layer-name">' + escapeHtml(meta.label) + '</span>' +
'<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
@ -2832,6 +2845,13 @@ function methodLabel(m) {
return { smrf: 'SMRF', csf: 'CSF', auto: 'Auto (détection)' }[m] || m;
}
function escapeHtml(s) {
// Noms de dalles/couches insérés en innerHTML : jamais de HTML brut,
// même si un fichier renommé dans input/ tente d'injecter du markup.
return String(s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
function humanSize(bytes) {
if (!Number.isFinite(bytes)) return '';
if (bytes < 1024) return bytes + ' o';
@ -2867,7 +2887,7 @@ function tileInfoContent(t) {
'<span class="tile-info-badge">' + res + ' m/px</span>' +
'<button class="tile-info-close" title="Fermer">×</button>' +
'</div>' +
'<div class="tile-info-dalle" title="' + t.name + '">' + t.name + '</div>' +
'<div class="tile-info-dalle" title="' + escapeHtml(t.name) + '">' + escapeHtml(t.name) + '</div>' +
'<div class="tile-info-meta">' +
(t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) +
(meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') +
@ -2891,10 +2911,10 @@ function tileInfoContent(t) {
pill.className = 'vpill' + (state.layers.on[k] ? ' on' : '');
pill.title = tip;
pill.innerHTML =
'<button class="vpill-eye" data-viz="' + k + '" title="Afficher / masquer cette couche">' +
'<button class="vpill-eye" data-viz="' + escapeHtml(k) + '" title="Afficher / masquer cette couche">' +
(state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<button class="vpill-name" data-viz="' + k + '" title="Afficher / masquer cette couche">' + label + '</button>' +
'<a class="vpill-dl" href="' + t.viz[k].full + '" download title="Télécharger l\'image">⤓</a>';
'<button class="vpill-name" data-viz="' + escapeHtml(k) + '" title="Afficher / masquer cette couche">' + escapeHtml(label) + '</button>' +
'<a class="vpill-dl" href="' + escapeHtml(t.viz[k].full) + '" download title="Télécharger l\'image">⤓</a>';
pills.appendChild(pill);
});