diff --git a/AGENTS.md b/AGENTS.md
index ebf841e..ecbfba8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,7 @@ This file provides guidance to AI Agents when working with code in this reposito
4. **Localization (i18n)**:
- Update translation files if there are changes to UI text or console messages.
+ - Frontend translation rendering must use safe DOM construction. Do not inject translated strings with non-clearing `innerHTML`; allowlist any intentional links and build them as DOM nodes.
5. **Documentation**:
- Always update `README.md` and all agent instruction files when making changes.
diff --git a/README.md b/README.md
index 357434e..f14225e 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ No more tab juggling, channel switching, or missing rewards â just set it, for
- âī¸ **Auto Channel Switching** â Always mines the best available stream
- đž **Persistent Login** â OAuth login saved via cookies
- đšī¸ **Simple Web UI** â Manage everything from your browser
+- đĄī¸ **Safe Frontend Rendering** â Dynamic UI content is rendered with DOM APIs to avoid HTML injection
- đ§Š **Docker-Ready** â One command to deploy anywhere
---
diff --git a/tests/test_frontend_dom_safety.py b/tests/test_frontend_dom_safety.py
new file mode 100644
index 0000000..e49f10f
--- /dev/null
+++ b/tests/test_frontend_dom_safety.py
@@ -0,0 +1,18 @@
+import re
+from pathlib import Path
+
+
+APP_JS = Path(__file__).resolve().parents[1] / "web" / "static" / "app.js"
+
+
+def test_app_js_only_uses_innerhtml_to_clear_elements():
+ app_source = APP_JS.read_text(encoding="utf-8")
+ unsafe_assignments = []
+
+ for match in re.finditer(r"\binnerHTML\s*=\s*([^;\n]+)", app_source):
+ assigned_value = match.group(1).strip()
+ if assigned_value not in {"''", '""'}:
+ line_number = app_source.count("\n", 0, match.start()) + 1
+ unsafe_assignments.append(f"line {line_number}: {match.group(0).strip()}")
+
+ assert unsafe_assignments == []
diff --git a/web/static/app.js b/web/static/app.js
index ed2e1a5..2ce8b96 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -353,7 +353,9 @@ function renderChannels() {
const channels = Object.values(state.channels);
if (channels.length === 0) {
const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...';
- container.innerHTML = `
`;
+ container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
} else {
const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.';
- container.innerHTML = `
${emptyMsg}
`;
+ container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
}
return;
}
@@ -1232,10 +1239,10 @@ function renderAvailableGames(games, filterText) {
games.forEach(game => {
const label = document.createElement('label');
label.className = 'game-checkbox';
- label.innerHTML = `
-
- ${game}
- `;
+ label.replaceChildren(
+ makeElement('input', { type: 'checkbox', value: game }),
+ makeElement('span', {}, game),
+ );
const checkbox = label.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked));
@@ -1528,7 +1535,7 @@ async function fetchAndPopulateLanguages() {
console.error('Failed to fetch languages:', error);
const languageSelect = document.getElementById('language');
if (languageSelect) {
- languageSelect.innerHTML = '';
+ languageSelect.replaceChildren(makeElement('option', { value: '' }, 'Failed to load languages'));
}
addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.');
}
@@ -1722,56 +1729,39 @@ function applyTranslations(t) {
// Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks)
const helpContent = helpTab.querySelector('.help-content');
if (helpContent) {
- // We only need to update the dynamic lists and the github link, preserving the headers we just updated?
- // Actually, the previous code was nuke-and-rebuild via innerHTML.
- // To keep it consistent with our "ID-based update" philosophy, we should probably avoid nuke-and-rebuild if possible,
- // OR just rebuild but include the IDs.
- // Since I added IDs to the static HTML, rebuilding via innerHTML will wipe them unless I include them in the template string.
- // Strategy: Update the dynamic parts (lists) safely, or just update the whole thing but INCLUDE the IDs.
- // Let's update the lists directly if possible, or just re-render properly.
+ const howToItems = t.gui.help.how_to_use_items || [
+ 'Login using your Twitch account (OAuth device code flow)',
+ 'Link your accounts at twitch.tv/drops/campaigns',
+ 'The miner will automatically discover campaigns and start mining',
+ 'Configure priority games in Settings to focus on what you want',
+ 'Monitor progress in the Main and Inventory tabs'
+ ];
+ const featuresItems = t.gui.help.features_items || [
+ 'Stream-less drop mining - saves bandwidth',
+ 'Game priority and exclusion lists',
+ 'Tracks up to 199 channels simultaneously',
+ 'Automatic channel switching',
+ 'Real-time progress tracking'
+ ];
+ const notesItems = t.gui.help.important_notes_items || [
+ 'Do not watch streams on the same account while mining',
+ 'Keep your cookies.jar file secure',
+ 'Requires linked game accounts for drops'
+ ];
- // Actually, the best approach for the lists is to find the UL/OL elements.
- // But they don't have IDs. TO be fully robust I should have added IDs to the lists too.
- // But for now, let's stick to the previous innerHTML approach but ensuring IDs are preserved in the template string.
- helpContent.innerHTML = `
-
-
- ${(t.gui.help.how_to_use_items || [
- 'Login using your Twitch account (OAuth device code flow)',
- 'Link your accounts at twitch.tv/drops/campaigns',
- 'The miner will automatically discover campaigns and start mining',
- 'Configure priority games in Settings to focus on what you want',
- 'Monitor progress in the Main and Inventory tabs'
- ]).map(item => `
${item}
`).join('')}
-
-
-
${t.gui.help.features || 'Features'}
-
- ${(t.gui.help.features_items || [
- 'Stream-less drop mining - saves bandwidth',
- 'Game priority and exclusion lists',
- 'Tracks up to 199 channels simultaneously',
- 'Automatic channel switching',
- 'Real-time progress tracking'
- ]).map(item => `
- ${(t.gui.help.important_notes_items || [
- 'Do not watch streams on the same account while mining',
- 'Keep your cookies.jar file secure',
- 'Requires linked game accounts for drops'
- ]).map(item => `