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
+7 -3
View File
@@ -118,10 +118,14 @@ def create_app(config=None):
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
# Still true: 76 inline event handlers remain across the templates, and
# no nonce can authorise those. Flip once tests/test_csp.py reports zero.
# Now false: every inline event handler has been replaced by a
# data-action attribute dispatched from main.js, so script-src no longer
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
# nonce. The escape hatch remains for a deployment that hits an
# overlooked handler — but leaving it on gives up the protection that
# would have blocked SEC-XSS-001.
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'true').lower() == 'true'
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
)
# Internationalisation. French is the site's primary language.
+38
View File
@@ -541,6 +541,44 @@ document.addEventListener('submit', function (event) {
}
});
/**
* Live value display next to a range input.
*
* <input type="range" data-mirror>
* <span>5</span>
*
* Replaces oninput="this.nextElementSibling.textContent = this.value",
* which the evaluation form repeated on all nine score sliders.
* data-mirror may name a selector; empty means the next sibling.
*/
document.addEventListener('input', function (event) {
const input = event.target.closest('[data-mirror]');
if (!input) {
return;
}
const selector = input.getAttribute('data-mirror');
const target = selector
? document.querySelector(selector)
: input.nextElementSibling;
if (target) {
target.textContent = input.value;
}
});
/**
* Submit the surrounding form when a control changes.
*
* <select data-submit-on-change>
*
* Replaces onchange="this.form.submit()".
*/
document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) {
control.form.submit();
}
});
/**
* Navigate on selection.
*
+24 -11
View File
@@ -9,16 +9,16 @@
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3>
<div class="header-actions">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="dayGridMonth">
<i class="fas fa-calendar"></i> Month
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
<i class="fas fa-calendar-week"></i> Week
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
<i class="fas fa-calendar-day"></i> Day
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="listMonth">
<i class="fas fa-list"></i> List
</button>
</div>
@@ -31,11 +31,11 @@
<!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
<div class="modal-backdrop" data-action="hide-create-event"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
<button class="modal-close" onclick="hideCreateEventModal()">&times;</button>
<button class="modal-close" data-action="hide-create-event">&times;</button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
@@ -49,7 +49,7 @@
<select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">-- Select a tryout --</option>
</select>
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
<button class="btn btn-sm btn-primary ml-2" data-action="go-tryout-match">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
@@ -64,7 +64,7 @@
<select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">-- Select a team --</option>
</select>
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
<button class="btn btn-sm btn-success ml-2" data-action="go-team-match">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
@@ -75,7 +75,7 @@
<div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
<p class="text-muted small">Create a brand new tryout event</p>
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
<button class="btn btn-sm btn-info" data-action="go-create-tryout">
<i class="fas fa-plus"></i> Create Tryout
</button>
</div>
@@ -86,11 +86,11 @@
<!-- Event Details Modal -->
<div id="eventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideEventModal()"></div>
<div class="modal-backdrop" data-action="hide-event-modal"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Event Details</h3>
<button class="modal-close" onclick="hideEventModal()">&times;</button>
<button class="modal-close" data-action="hide-event-modal">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent"></div>
@@ -434,5 +434,18 @@ function toggleCalendarPresence(matchId, participantId, btn) {
function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden');
}
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'change-view': function (element) {
changeView(element.getAttribute('data-view'));
},
'hide-create-event': hideCreateEventModal,
'go-tryout-match': goToTryoutMatch,
'go-team-match': goToTeamMatch,
'go-create-tryout': goToCreateTryout,
'hide-event-modal': hideEventModal,
});
</script>
{% endblock %}
+9 -9
View File
@@ -32,21 +32,21 @@
<div class="form-group col-4">
<label for="mecanics_score">Mecanics (1-10)</label>
<div class="score-input">
<input type="range" id="mecanics_score" name="mecanics_score" min="1" max="10" value="{{ existing_eval.mecanics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="mecanics_score" name="mecanics_score" min="1" max="10" value="{{ existing_eval.mecanics_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.mecanics_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="cohesion_score">Cohesion (1-10)</label>
<div class="score-input">
<input type="range" id="cohesion_score" name="cohesion_score" min="1" max="10" value="{{ existing_eval.cohesion_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="cohesion_score" name="cohesion_score" min="1" max="10" value="{{ existing_eval.cohesion_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.cohesion_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="communication_score">Communication (1-10)</label>
<div class="score-input">
<input type="range" id="communication_score" name="communication_score" min="1" max="10" value="{{ existing_eval.communication_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="communication_score" name="communication_score" min="1" max="10" value="{{ existing_eval.communication_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.communication_score or 5 }}</span>
</div>
</div>
@@ -56,21 +56,21 @@
<div class="form-group col-4">
<label for="gamesense_score">Gamesense (1-10)</label>
<div class="score-input">
<input type="range" id="gamesense_score" name="gamesense_score" min="1" max="10" value="{{ existing_eval.gamesense_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="gamesense_score" name="gamesense_score" min="1" max="10" value="{{ existing_eval.gamesense_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.gamesense_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="versatility_score">Versatility (1-10)</label>
<div class="score-input">
<input type="range" id="versatility_score" name="versatility_score" min="1" max="10" value="{{ existing_eval.versatility_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="versatility_score" name="versatility_score" min="1" max="10" value="{{ existing_eval.versatility_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.versatility_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="discipline_score">Discipline (1-10)</label>
<div class="score-input">
<input type="range" id="discipline_score" name="discipline_score" min="1" max="10" value="{{ existing_eval.discipline_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="discipline_score" name="discipline_score" min="1" max="10" value="{{ existing_eval.discipline_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.discipline_score or 5 }}</span>
</div>
</div>
@@ -80,21 +80,21 @@
<div class="form-group col-4">
<label for="analysis_score">Analysis (1-10)</label>
<div class="score-input">
<input type="range" id="analysis_score" name="analysis_score" min="1" max="10" value="{{ existing_eval.analysis_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="analysis_score" name="analysis_score" min="1" max="10" value="{{ existing_eval.analysis_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.analysis_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="sport_ethics_score">Sport Ethics (1-10)</label>
<div class="score-input">
<input type="range" id="sport_ethics_score" name="sport_ethics_score" min="1" max="10" value="{{ existing_eval.sport_ethics_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="sport_ethics_score" name="sport_ethics_score" min="1" max="10" value="{{ existing_eval.sport_ethics_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.sport_ethics_score or 5 }}</span>
</div>
</div>
<div class="form-group col-4">
<label for="mental_score">Mental (1-10)</label>
<div class="score-input">
<input type="range" id="mental_score" name="mental_score" min="1" max="10" value="{{ existing_eval.mental_score or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<input type="range" id="mental_score" name="mental_score" min="1" max="10" value="{{ existing_eval.mental_score or 5 }}" data-mirror>
<span class="range-value">{{ existing_eval.mental_score or 5 }}</span>
</div>
</div>
+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 %}
+1 -1
View File
@@ -170,7 +170,7 @@
{% if coach %}
<!-- Hidden data for JavaScript -->
<script id="coach-availability-data" type="application/json">
<script id="coach-availability-data" type="application/json" nonce="{{ csp_nonce }}">
{{ coach_availability | tojson }}
</script>
{% endif %}
+20 -11
View File
@@ -5,7 +5,7 @@
{% block header_actions %}
{% if can_manage %}
<button class="btn btn-primary" onclick="showCreateForm()">
<button class="btn btn-primary" data-action="show-create-form">
<i class="fas fa-plus"></i> New Team
</button>
{% endif %}
@@ -45,7 +45,7 @@
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="hideCreateForm()">Cancel</button>
<button type="button" class="btn btn-secondary" data-action="hide-create-form">Cancel</button>
<button type="submit" class="btn btn-primary">Create Team</button>
</div>
</form>
@@ -63,7 +63,7 @@
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
<i class="fas fa-edit"></i> Edit
</button>
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Delete team {{ team.name }}? This will unassign it from any linked tryouts.')">
<form method="POST" action="{{ url_for('teams.delete_team', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Delete team %(name)s? It will be unassigned from any linked tryouts.', name=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i> Delete
@@ -89,7 +89,7 @@
<span class="staff-tag">
{{ c.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove coach {{ c.username }} from {{ team.name }}?')">
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove coach %(coach)s from %(team)s?', coach=c.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
<button type="submit" class="btn-icon-sm" title="Remove Coach">&times;</button>
@@ -111,7 +111,7 @@
<span class="staff-tag manager-tag">
{{ m.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove manager {{ m.username }} from {{ team.name }}?')">
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" data-confirm="{{ _('Remove manager %(manager)s from %(team)s?', manager=m.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
<button type="submit" class="btn-icon-sm" title="Remove Manager">&times;</button>
@@ -166,7 +166,7 @@
<button class="btn btn-sm status-toggle-btn {% if entry.status == 'starter' %}btn-success{% else %}btn-warning{% endif %}"
data-team-id="{{ team.id }}"
data-player-id="{{ entry.player.id }}"
onclick="toggleStatus(this)">
data-action="toggle-status">
{{ entry.status | capitalize }}
</button>
{% else %}
@@ -180,7 +180,7 @@
<td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %}
<td>
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ entry.player.username }} from {{ team.name }}?')">
<form method="POST" action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-user-minus"></i> Remove
@@ -240,7 +240,7 @@
<h3>No teams yet</h3>
{% if can_manage %}
<p>Create organization teams and assign coaches to manage tryouts.</p>
<button class="btn btn-primary" onclick="showCreateForm()">Create Team</button>
<button class="btn btn-primary" data-action="show-create-form">Create Team</button>
{% else %}
<p>There are no teams to display.</p>
{% endif %}
@@ -251,11 +251,11 @@
<!-- Edit Team Modal -->
<div id="editTeamModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideEditForm()"></div>
<div class="modal-backdrop" data-action="hide-edit-form"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Edit Team</h3>
<button class="modal-close" onclick="hideEditForm()">&times;</button>
<button class="modal-close" data-action="hide-edit-form">&times;</button>
</div>
<div class="modal-body">
<form id="editTeamForm" method="POST" action="" class="form">
@@ -292,7 +292,7 @@
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="hideEditForm()">Cancel</button>
<button type="button" class="btn btn-secondary" data-action="hide-edit-form">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
@@ -405,6 +405,15 @@ function populateEditSelects(teamId) {
function hideEditForm() {
document.getElementById('editTeamModal').classList.add('hidden');
}
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'show-create-form': showCreateForm,
'hide-create-form': hideCreateForm,
'hide-edit-form': hideEditForm,
'toggle-status': toggleStatus,
});
</script>
<style>
/* === Full-width staff bar layout === */
+21 -8
View File
@@ -17,7 +17,7 @@
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_status', tryout_id=tryout.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" onchange="this.form.submit()" class="form-select">
<select name="status" data-submit-on-change class="form-select">
<option value="upcoming" {% if tryout.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
<option value="in_progress" {% if tryout.status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="completed" {% if tryout.status == 'completed' %}selected{% endif %}>Completed</option>
@@ -30,7 +30,7 @@
</a>
{% endif %}
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.delete_tryout', tryout_id=tryout.id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this entire tryout? This will remove all matches, teams, registrations, and evaluations.');">
<form method="POST" action="{{ url_for('tryouts.delete_tryout', tryout_id=tryout.id) }}" class="inline-form" data-confirm="{{ _('Delete this entire tryout? This removes all its matches, teams, registrations and evaluations.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i> Delete Tryout
@@ -169,7 +169,7 @@
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.update_registration_status', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="status" onchange="this.form.submit()" class="form-select form-select-sm">
<select name="status" data-submit-on-change class="form-select form-select-sm">
<option value="registered" {% if reg.status == 'registered' %}selected{% endif %}>Registered</option>
<option value="attended" {% if reg.status == 'attended' %}selected{% endif %}>Attended</option>
<option value="no_show" {% if reg.status == 'no_show' %}selected{% endif %}>No Show</option>
@@ -199,7 +199,7 @@
</a>
{% endif %}
{% if not tryout.is_ended %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ p.username }} from this tryout? This will also remove them from all teams and matches within this tryout.');">
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" data-confirm="{{ _('Remove %(name)s from this tryout? They will also be removed from every team and match within it.', name=p.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Remove player from tryout">
<i class="fas fa-user-minus"></i> Remove
@@ -226,7 +226,7 @@
<div class="card-header">
<h3><i class="fas fa-users-cog"></i> Teams</h3>
{% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-primary" onclick="showCreateTeam()">
<button class="btn btn-sm btn-primary" data-action="show-create-team">
<i class="fas fa-plus"></i> New Team
</button>
{% endif %}
@@ -238,7 +238,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" name="team_name" placeholder="Team name" required class="form-input mr-2">
<button type="submit" class="btn btn-sm btn-success">Create</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="hideCreateTeam()">Cancel</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="hide-create-team">Cancel</button>
</form>
</div>
{% endif %}
@@ -368,7 +368,7 @@
{% for pp in item.player_presence %}
<button class="btn btn-xs presence-toggle-btn {% if pp.attendance_confirmed %}presence-confirmed-btn{% else %}presence-pending-btn{% endif %}"
title="{{ pp.player_name }}"
onclick="toggleTryoutPresence({{ m.id }}, {{ pp.participant_id }}, this)">
data-action="toggle-tryout-presence" data-match-id="{{ m.id }}" data-participant-id="{{ pp.participant_id }}">
{{ pp.player_name[:2] | upper }} {% if pp.attendance_confirmed %}✅{% else %}⏳{% endif %}
</button>
{% endfor %}
@@ -381,7 +381,7 @@
<div class="presence-players" style="margin-top:6px;">
<button class="btn btn-xs presence-toggle-btn {% if pp.attendance_confirmed %}presence-confirmed-btn{% else %}presence-pending-btn{% endif %}"
title="Toggle your attendance"
onclick="toggleTryoutPresence({{ m.id }}, {{ pp.participant_id }}, this)">
data-action="toggle-tryout-presence" data-match-id="{{ m.id }}" data-participant-id="{{ pp.participant_id }}">
Me {% if pp.attendance_confirmed %}✅{% else %}⏳{% endif %}
</button>
</div>
@@ -614,5 +614,18 @@ document.addEventListener('DOMContentLoaded', function() {
miniCalendar.render();
}
});
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam,
'toggle-tryout-presence': function (element) {
toggleTryoutPresence(
element.getAttribute('data-match-id'),
element.getAttribute('data-participant-id'),
element);
},
});
</script>
{% endblock %}
+46 -46
View File
@@ -1,17 +1,15 @@
"""Content Security Policy, and the migration away from 'unsafe-inline'.
"""Content Security Policy.
SEC-WEB-001 / OPS-010. script-src still carries 'unsafe-inline', which is
why the stored XSS of SEC-XSS-001 executed instead of being blocked.
SEC-WEB-001 / OPS-010. script-src no longer carries 'unsafe-inline': the
directive that let the stored XSS of SEC-XSS-001 execute instead of being
blocked. Inline scripts are authorised by a per-request nonce, and every
inline event handler has been replaced by a data-action attribute
dispatched from main.js.
Removing it is not a one-line change. A nonce authorises `<script>`
elements; it can do nothing for `onclick="..."` attributes, and there are
dozens of those across the templates. Under CSP level 3 a browser also
ignores 'unsafe-inline' the moment a nonce appears, so the two cannot
coexist as a gradual transition — the switch is atomic.
The counts below are a ratchet: they may only go down. Migrating a
template and lowering the number is a deliberate act, recorded in the
diff. Adding a new inline handler turns the suite red.
Getting here took removing 82 handlers across 17 templates, because a nonce
authorises `<script>` elements and can do nothing for `onclick`. These
tests keep it that way: one new inline handler, and the policy silently
stops applying to that page.
"""
import os
@@ -34,13 +32,9 @@ INLINE_HANDLER = re.compile(
#: Remaining inline handlers, per template. Lower these as you migrate;
#: never raise one. Templates absent from this map must have none.
HANDLER_BUDGET = {
'pages/match_form.html': 13,
'pages/calendar.html': 11,
'pages/teams.html': 11,
'pages/evaluate_player.html': 9,
'pages/view_tryout.html': 8,
}
#: No template may carry an inline event handler. The migration is done;
#: this is now a hard rule, not a countdown.
HANDLER_BUDGET = {}
#: What the ratchet is counting down to.
TOTAL_BUDGET = sum(HANDLER_BUDGET.values())
@@ -61,10 +55,12 @@ def _count_handlers(path):
class TestPolicyHeader:
def test_the_current_policy_still_allows_inline_script(self, client):
"""Documents where we are, not where we want to be."""
def test_script_src_no_longer_allows_inline(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
assert "'unsafe-inline'" in csp
script_src = next(d for d in csp.split(';') if 'script-src' in d)
assert "'unsafe-inline'" not in script_src
assert "'nonce-" in script_src
def test_the_policy_pins_the_dangerous_directives(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
@@ -75,10 +71,13 @@ class TestPolicyHeader:
assert "form-action 'self'" in csp
assert "object-src" not in csp or "object-src 'none'" in csp
def test_no_nonce_is_emitted_while_inline_script_is_allowed(self, client):
"""Emitting both would silently drop every inline script in modern
browsers, since a nonce makes them ignore 'unsafe-inline'."""
csp = client.get('/auth/login').headers['Content-Security-Policy']
def test_the_legacy_mode_still_builds(self):
"""The escape hatch is kept for a deployment that hits an overlooked
handler. Emitting both would be pointless: a nonce makes browsers
ignore 'unsafe-inline' entirely."""
csp = build_csp(allow_inline_script=True)
assert "'unsafe-inline'" in csp
assert 'nonce-' not in csp
def test_the_hardened_policy_carries_a_nonce_and_no_unsafe_inline(self):
@@ -113,34 +112,35 @@ class TestInlineHandlerRatchet:
f'data-action="..." and the delegated listener in main.js.'
)
def test_the_budget_map_has_no_stale_entries(self):
"""Lower an entry to zero and it must be deleted, so the map keeps
reflecting the real remaining work."""
actual = {rel: _count_handlers(full) for rel, full in _templates()}
def test_every_inline_script_block_carries_a_nonce(self):
"""Without a nonce a block is simply not executed now, and nothing
in the server logs says so."""
import re as _re
stale = [rel for rel, allowed in HANDLER_BUDGET.items()
if actual.get(rel, 0) < allowed]
offenders = []
for relative, full in _templates():
with open(full, encoding='utf-8') as handle:
content = handle.read()
for tag in _re.findall(r'<script[^>]*>', content):
if 'src=' in tag or 'nonce=' in tag:
continue
offenders.append(f'{relative}: {tag}')
assert not stale, (
f'Budget is now higher than reality for {stale}. Lower or remove '
f'these entries so the count keeps meaning something.'
assert not offenders, (
'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
)
def test_the_shared_layout_is_already_free_of_them(self):
"""base.html and macros.html render on every single page, so they
were migrated first."""
def test_the_shared_layout_is_free_of_them(self):
"""base.html and macros.html render on every single page."""
for relative in ('layouts/base.html', 'layouts/macros.html'):
full = os.path.join(TEMPLATE_ROOT, *relative.split('/'))
assert _count_handlers(full) == 0, f'{relative} regressed'
def test_progress_is_recorded(self):
"""Fails when the total drops, as a reminder to update the budget
and, once it reaches zero, to flip CSP_ALLOW_INLINE_SCRIPT."""
def test_no_template_carries_an_inline_handler(self):
total = sum(_count_handlers(full) for _rel, full in _templates())
assert total <= TOTAL_BUDGET
assert total == TOTAL_BUDGET, (
f'{TOTAL_BUDGET - total} handler(s) removed since the budget was '
f'last updated — lower HANDLER_BUDGET to {total}. At zero, set '
f'CSP_ALLOW_INLINE_SCRIPT to false and delete this ratchet.'
assert total == 0, (
f'{total} inline event handler(s) reintroduced. They are not '
f'covered by the nonce, so they will not run — use '
f'data-action="..." and registerActions() instead.'
)
+3 -6
View File
@@ -1,6 +1,5 @@
"""HTTP hardening, error disclosure, and template escaping."""
import pytest
from app.app import nl2br
@@ -19,15 +18,13 @@ class TestSecurityHeaders:
last implementations."""
assert 'X-XSS-Protection' not in client.get('/auth/login').headers
@pytest.mark.xfail(
strict=True,
reason="SEC-WEB-001: 15 inline <script> blocks still require "
"'unsafe-inline'; lifting it is tracked as OPS-010",
)
def test_csp_does_not_allow_inline_script(self, client):
"""SEC-WEB-001, closed: inline scripts are authorised by a
per-request nonce instead."""
csp = client.get('/auth/login').headers['Content-Security-Policy']
script_src = [d for d in csp.split(';') if d.strip().startswith('script-src')][0]
assert "'unsafe-inline'" not in script_src
assert "'nonce-" in script_src
class TestErrorDisclosure: