diff --git a/app/routes/matches.py b/app/routes/matches.py
index a9dc0cf..6c42699 100644
--- a/app/routes/matches.py
+++ b/app/routes/matches.py
@@ -48,7 +48,12 @@ def api_events():
for tryout in tryouts:
for match in tryout.matches:
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
- match_desc = match.description or ''
+ # 'description' used to be participants_str + '
' + description.
+ # Building presentation markup inside a JSON field is what carried
+ # the stored XSS: the browser dropped it straight into innerHTML,
+ # and player usernames travelled through it unescaped. The two
+ # values are already separate keys, so the concatenation also made
+ # the modal show the participants twice.
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
@@ -56,14 +61,12 @@ def api_events():
teams.append(match.team1.name)
if match.team2:
teams.append(match.team2.name)
- participants_str = f"{' vs '.join(teams)}"
- match_desc = participants_str + (f"
{match.description}" if match.description else '')
+ participants_str = ' vs '.join(teams)
else:
player_names = []
for p in match.participants.all():
player_names.append(p.player.username if p.player else 'Unknown Player')
participants_str = ', '.join(player_names) if player_names else 'No players'
- match_desc = participants_str + (f"
{match.description}" if match.description else '')
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
@@ -79,7 +82,7 @@ def api_events():
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
- 'status': match.status, 'description': match_desc,
+ 'status': match.status, 'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id, 'match_id': match.id,
'start_time': start_time_str, 'end_time': end_time_str,
diff --git a/app/templates/pages/calendar.html b/app/templates/pages/calendar.html
index b984ad0..2290230 100644
--- a/app/templates/pages/calendar.html
+++ b/app/templates/pages/calendar.html
@@ -205,14 +205,16 @@ function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); })
.then(function(data) {
+ // new Option() sets the label as text; concatenating it into
+ // innerHTML let a tryout title carry markup into the page.
var sel = document.getElementById('createTryoutSelect');
- sel.innerHTML = '';
+ sel.replaceChildren(new Option('-- Select a tryout --', ''));
var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) {
// Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) {
- sel.innerHTML += '';
+ sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
}
});
})
@@ -224,70 +226,124 @@ function fetchTeamOptions() {
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTeamSelect');
- sel.innerHTML = '';
+ sel.replaceChildren(new Option('-- Select a team --', ''));
data.forEach(function(t) {
- sel.innerHTML += '';
+ sel.appendChild(new Option(t.name, t.id));
});
})
.catch(function() {});
}
+// ---------------------------------------------------------------------------
+// Safe DOM builders
+// ---------------------------------------------------------------------------
+// Everything rendered in the event modal originates from a JSON endpoint,
+// where no HTML escaping applies. Text therefore goes through textContent,
+// never through innerHTML.
+
+function makeEl(tag, className, text) {
+ var node = document.createElement(tag);
+ if (className) { node.className = className; }
+ if (text !== undefined && text !== null) { node.textContent = text; }
+ return node;
+}
+
+function detailItem(label, valueNode, fullWidth) {
+ var item = makeEl('div', 'detail-item' + (fullWidth ? ' full-width' : ''));
+ item.appendChild(makeEl('span', 'detail-label', label));
+ var value = makeEl('span', 'detail-value');
+ value.appendChild(valueNode);
+ item.appendChild(value);
+ return item;
+}
+
+// Renders newlines as
without letting any other markup through.
+function multilineNode(text) {
+ var fragment = document.createDocumentFragment();
+ String(text).split('\n').forEach(function(line, index) {
+ if (index > 0) { fragment.appendChild(document.createElement('br')); }
+ fragment.appendChild(document.createTextNode(line));
+ });
+ return fragment;
+}
+
+// A team block: its name, plus an optional list of player names.
+function teamNode(name, players) {
+ var team = makeEl('div', 'match-team');
+ team.appendChild(makeEl('span', 'team-name', name));
+ if (players) {
+ var list = makeEl('ul', 'team-players-list');
+ players.forEach(function(player) {
+ list.appendChild(makeEl('li', null, player));
+ });
+ team.appendChild(list);
+ }
+ return team;
+}
+
+function versusNode(left, right) {
+ var wrap = makeEl('div', 'match-teams');
+ wrap.appendChild(left);
+ wrap.appendChild(makeEl('div', 'match-vs', 'vs'));
+ wrap.appendChild(right);
+ return wrap;
+}
+
+function buildTeamsNode(props) {
+ var sides = props.participants.split(' vs ');
+
+ if (props.match_type === 'team_vs_team' && sides.length >= 2) {
+ return versusNode(teamNode(sides[0]), teamNode(sides[1]));
+ }
+
+ if (props.match_type === 'player_vs_player' && sides.length >= 2) {
+ return versusNode(
+ teamNode('Team 1', sides[0].split(', ')),
+ teamNode('Team 2', sides[1].split(', '))
+ );
+ }
+
+ return document.createTextNode(props.participants);
+}
+
function showEventModal(event) {
var props = event.extendedProps;
var title = event.title;
var type = props.type;
var date = event.start ? event.start.toDateString() : '';
- var content = '