fix(gui): harden settings and background flows

Preserve masked secrets on settings save, tolerate malformed background add requests, escape background catalog values, and skip terminal clearing when TERM is unset.

Tested: rtk docker compose run --rm test
pull/2550/head
Hong Phuc 3 weeks ago
parent 6c37d42e43
commit d3fdd4145b

@ -75,13 +75,13 @@ def backgrounds():
return render_template("backgrounds.html", file="backgrounds.json")
@app.route("/background/add", methods=["POST"])
def background_add():
# Get form values
youtube_uri = request.form.get("youtube_uri").strip()
filename = request.form.get("filename").strip()
citation = request.form.get("citation").strip()
position = request.form.get("position").strip()
@app.route("/background/add", methods=["POST"])
def background_add():
# Get form values
youtube_uri = request.form.get("youtube_uri", "").strip()
filename = request.form.get("filename", "").strip()
citation = request.form.get("citation", "").strip()
position = request.form.get("position", "").strip()
gui.add_background(youtube_uri, filename, citation, position)

@ -108,6 +108,15 @@
let keys = [];
let youtube_urls = [];
function h(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function loadBackgrounds() {
try {
const response = await fetch("backgrounds.json");
@ -134,8 +143,8 @@
allowfullscreen></iframe>
</div>
<div class="p-4">
<h3 class="text-slate-200 font-medium truncate mb-1" title="${key}">${key}</h3>
<p class="text-slate-500 text-xs truncate mb-4">${value[2]}</p>
<h3 class="text-slate-200 font-medium truncate mb-1" title="${h(key)}">${h(key)}</h3>
<p class="text-slate-500 text-xs truncate mb-4">${h(value[2])}</p>
<div class="flex justify-end">
<button onclick="confirmDelete('${key}')" class="btn btn-square btn-sm btn-ghost hover:bg-red-500/20 hover:text-red-400">

@ -1,5 +1,6 @@
#!/usr/bin/env python
import math
import os
import subprocess
import sys
from os import name
@ -59,6 +60,12 @@ def _get_platform_post_id(config: dict, platform: str) -> str:
return ""
def clear_screen() -> None:
if name != "nt" and not os.environ.get("TERM"):
return
subprocess.run(["cls" if name == "nt" else "clear"], shell=(name == "nt"))
def main(POST_ID=None) -> None:
global reddit_id, reddit_object
reddit_object = get_content_object(POST_ID)
@ -97,7 +104,7 @@ def run_many(times) -> None:
f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
)
main()
subprocess.run(["cls" if name == "nt" else "clear"], shell=(name == "nt"))
clear_screen()
def shutdown() -> NoReturn:
@ -145,7 +152,7 @@ if __name__ == "__main__":
f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {num_posts}'
)
main(post_id)
subprocess.run(["cls" if name == "nt" else "clear"], shell=(name == "nt"))
clear_screen()
elif config["settings"]["times_to_run"]:
run_many(config["settings"]["times_to_run"])
else:

@ -0,0 +1,30 @@
from unittest.mock import patch
from GUI import app
def test_background_add_handles_missing_form_fields():
app.testing = False
response = app.test_client().post("/background/add", data={})
assert response.status_code == 302
assert response.headers["Location"].endswith("/backgrounds")
def test_background_add_passes_empty_defaults_for_missing_optional_fields():
app.testing = True
with patch("GUI.gui.add_background") as add_background:
response = app.test_client().post(
"/background/add",
data={"youtube_uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
)
assert response.status_code == 302
add_background.assert_called_once_with(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"",
"",
"",
)

@ -70,3 +70,32 @@ def test_add_background(mock_flash, mock_background_json, mock_template_toml):
assert "test_new" in template_data["settings"]["background"]["background_video"]["options"]
mock_flash.assert_called_with('Added "Rick-test_new.mp4" as a new background video!')
@patch("utils.gui_utils.flash")
def test_modify_settings_preserves_masked_secrets(mock_flash):
config_load = {
"reddit": {
"creds": {
"client_secret": "real-secret",
"password": "real-password",
}
}
}
checks = {
"reddit.creds.client_secret": {"optional": False, "type": "str"},
"reddit.creds.password": {"optional": False, "type": "str"},
}
result = gui_utils.modify_settings(
{
"reddit.creds.client_secret": "********",
"reddit.creds.password": "changed-password",
},
config_load,
checks,
)
assert config_load["reddit"]["creds"]["client_secret"] == "real-secret"
assert config_load["reddit"]["creds"]["password"] == "changed-password"
assert result["reddit.creds.client_secret"] == "real-secret"

@ -0,0 +1,34 @@
import os
from unittest.mock import patch
import main
def test_clear_screen_skips_clear_without_term(monkeypatch):
monkeypatch.delenv("TERM", raising=False)
monkeypatch.setattr(main, "name", "posix")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_not_called()
def test_clear_screen_runs_when_term_is_set(monkeypatch):
monkeypatch.setenv("TERM", "xterm")
monkeypatch.setattr(main, "name", "posix")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_called_once_with(["clear"], shell=False)
def test_clear_screen_runs_windows_command(monkeypatch):
monkeypatch.delenv("TERM", raising=False)
monkeypatch.setattr(main, "name", "nt")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_called_once_with(["cls"], shell=True)

@ -0,0 +1,10 @@
from pathlib import Path
def test_backgrounds_template_escapes_catalog_values_before_inner_html():
template = Path("GUI/backgrounds.html").read_text(encoding="utf-8")
assert "function h(str)" in template
assert "title=\"${h(key)}\"" in template
assert "${h(key)}" in template
assert "${h(value[2])}" in template

@ -7,6 +7,22 @@ import tomlkit
from flask import flash
MASKED_SECRET_VALUE = "********"
SENSITIVE_SETTING_PARTS = {
"password",
"client_secret",
"access_token",
"2fa_secret",
"tiktok_sessionid",
"elevenlabs_api_key",
"openai_api_key",
}
def is_sensitive_setting(name: str) -> bool:
return any(part in name for part in SENSITIVE_SETTING_PARTS)
# Get validation checks from template, keyed by dotted path
# (e.g. "reddit.creds.username", "threads.creds.username") so that
# leaf-key collisions across platform sections don't clobber each other.
@ -113,6 +129,9 @@ def modify_settings(data: dict, config_load, checks: dict):
# Validate and apply values
for name, raw_value in data.items():
if is_sensitive_setting(name) and raw_value == MASKED_SECRET_VALUE:
continue
value = check(raw_value, checks[name])
# Value is invalid

Loading…
Cancel
Save