feat(csp): retirer unsafe-inline de script-src

SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.

Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.

Deux motifs generiques de plus dans main.js
  data-mirror             affichage direct de la valeur d'un curseur.
                          evaluate_player repetait le meme
                          oninput="this.nextElementSibling.textContent = ..."
                          sur ses neuf curseurs de note.
  data-submit-on-change   remplace onchange="this.form.submit()"

Markup genere dans des chaines JavaScript
  match_form construisait sept gestionnaires par concatenation, en y
  injectant l'identifiant du joueur. Le markup portait deja data-player-id :
  returnToPool et assignToTeam lisent desormais leurs arguments depuis
  l'element clique. Cela supprime a la fois l'attribut en ligne et la
  concatenation qui l'alimentait. Meme motif que dans coach_availability.

Bascule
  CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
  'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
  La variable d'environnement reste, comme issue de secours si un
  deploiement rencontrait un gestionnaire oublie -- mais la laisser active
  revient a renoncer a la protection.

Le cliquet devient une garde
  Le budget par gabarit est vide et les tests deviennent absolus : aucun
  gestionnaire en ligne, et tout bloc <script> inline doit porter son
  nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
  journaux ne le signale -- d'ou le test.

Verifications
  22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
  ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
  nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
  par node --check.

193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.

style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 21:03:31 -04:00
co-authored by Claude Opus 5
parent 09453199b8
commit fcb58e8a17
10 changed files with 209 additions and 109 deletions
+40 -14
View File
@@ -32,7 +32,7 @@
{% if not match %}
<div class="form-group">
<label for="match_type">Match Type</label>
<select name="match_type" id="match_type" class="form-select" onchange="toggleMatchType()" required>
<select name="match_type" id="match_type" class="form-select" data-change="toggle-match-type" required>
<option value="team_vs_team">Team vs Team</option>
<option value="player_vs_player">Player vs Player</option>
<option value="player_scrim">Player Scrim</option>
@@ -77,7 +77,7 @@
<h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<p class="text-muted small" style="margin:0;">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="clearTimeSelection()" title="Reset time selection">
<button type="button" class="btn btn-sm btn-outline" data-action="clear-time-selection" title="Reset time selection">
<i class="fas fa-undo"></i> Reset Time
</button>
</div>
@@ -154,7 +154,7 @@
<span class="presence-badge {{ 'presence-confirmed' if pdata.attendance_confirmed else 'presence-pending' }}"
data-participant-id="{{ pdata.participant_id }}"
data-match-id="{{ match.id }}"
onclick="togglePresence({{ match.id }}, {{ pdata.participant_id }}, this)"
data-action="toggle-presence" data-match-id="{{ match.id }}" data-participant-id="{{ pdata.participant_id }}"
title="Click to toggle presence">
{{ '✅ Confirmed' if pdata.attendance_confirmed else '⏳ Pending' }}
</span>
@@ -180,7 +180,7 @@
<span class="presence-badge {{ 'presence-confirmed' if pdata.attendance_confirmed else 'presence-pending' }}"
data-participant-id="{{ pdata.participant_id }}"
data-match-id="{{ match.id }}"
onclick="togglePresence({{ match.id }}, {{ pdata.participant_id }}, this)"
data-action="toggle-presence" data-match-id="{{ match.id }}" data-participant-id="{{ pdata.participant_id }}"
title="Click to toggle presence">
{{ '✅ Confirmed' if pdata.attendance_confirmed else '⏳ Pending' }}
</span>
@@ -204,10 +204,10 @@
<div class="randomize-section">
<div class="randomize-controls">
<label><i class="fas fa-random"></i> Randomize Teams:</label>
<input type="number" id="team1-size" class="randomize-input" min="1" value="1" onchange="updateRandomizePreview()">
<input type="number" id="team1-size" class="randomize-input" min="1" value="1" data-change="update-randomize-preview">
<span>vs</span>
<span id="team2-size-preview" class="randomize-preview">0</span>
<button type="button" class="btn btn-sm randomize-btn" onclick="randomizeTeams()">
<button type="button" class="btn btn-sm randomize-btn" data-action="randomize-teams">
<i class="fas fa-random"></i> Randomize
</button>
</div>
@@ -554,7 +554,7 @@ document.addEventListener('DOMContentLoaded', function() {
var teamDiv = document.getElementById('team1-selection');
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" onclick="returnToPool(' + pid + ', event)">';
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -567,7 +567,7 @@ document.addEventListener('DOMContentLoaded', function() {
var teamDiv = document.getElementById('team2-selection');
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" onclick="returnToPool(' + pid + ', event)">';
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -922,8 +922,8 @@ function updatePlayerPool() {
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
html += '<span class="player-name">' + playerName + '</span>';
html += '<div class="player-actions">';
html += '<button type="button" class="btn btn-sm btn-primary" onclick="assignToTeam(' + pid + ', 1)">T1</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" onclick="assignToTeam(' + pid + ', 2)">T2</button>';
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">T1</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">T2</button>';
html += '</div>';
html += '</div>';
});
@@ -942,7 +942,7 @@ function assignToTeam(playerId, teamSide) {
var playerName = playerDataById.player_data[playerId];
if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" onclick="returnToPool(' + playerId + ', event)">';
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
html += playerName;
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -952,8 +952,12 @@ function assignToTeam(playerId, teamSide) {
updatePlayerPool();
}
function returnToPool(playerId, event) {
function returnToPool(element, event) {
// The clicked element already carries data-player-id: the generated
// markup sets it, so the value no longer has to be baked into an
// onclick attribute.
if (event) event.stopPropagation();
var playerId = element.getAttribute('data-player-id');
document.querySelectorAll('[data-player-id="' + playerId + '"]').forEach(function(el) {
el.remove();
});
@@ -1057,7 +1061,7 @@ function randomizeTeams() {
var teamDiv = document.getElementById('team1-selection');
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" onclick="returnToPool(' + pid + ', event)">';
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -1069,7 +1073,7 @@ function randomizeTeams() {
var teamDiv = document.getElementById('team2-selection');
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" onclick="returnToPool(' + pid + ', event)">';
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -1110,5 +1114,27 @@ function togglePresence(matchId, participantId, badgeEl) {
console.error('Error toggling presence:', error);
});
}
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'toggle-match-type': toggleMatchType,
'clear-time-selection': clearTimeSelection,
'update-randomize-preview': updateRandomizePreview,
'randomize-teams': randomizeTeams,
'return-to-pool': returnToPool,
'assign-team': function (element) {
var item = element.closest('[data-player-id]');
if (item) {
assignToTeam(item.getAttribute('data-player-id'),
element.getAttribute('data-team-side'));
}
},
'toggle-presence': function (element) {
togglePresence(element.getAttribute('data-match-id'),
element.getAttribute('data-participant-id'),
element);
},
});
</script>
{% endblock %}