diff --git a/app.py b/app.py index 818d1a2..22c3f1e 100644 --- a/app.py +++ b/app.py @@ -6,12 +6,13 @@ import logging import os +import secrets import sys from datetime import datetime from pathlib import Path from typing import cast -from flask import Flask, Response, render_template, send_from_directory +from flask import Flask, Response, g, render_template, send_from_directory from utils.debug_flag import resolve_debug_flag @@ -24,6 +25,31 @@ from api.config_api import bp as config_bp from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules +_CDNJS_ORIGIN = "https://cdnjs.cloudflare.com" + + +def build_content_security_policy(nonce: str) -> str: + """Build a restrictive CSP for served HTML pages. + + script-src allows self-hosted JS, SRI-pinned cdnjs assets, and one + per-response nonce for inline page scripts. style-src keeps + 'unsafe-inline' because highlight.js themes apply inline styles. + """ + return "; ".join( + [ + "default-src 'self'", + f"script-src 'self' {_CDNJS_ORIGIN} 'nonce-{nonce}'", + f"style-src 'self' 'unsafe-inline' {_CDNJS_ORIGIN}", + "img-src 'self' data:", + "connect-src 'self'", + "font-src 'self'", + "object-src 'none'", + "form-action 'self'", + "base-uri 'self'", + "frame-ancestors 'none'", + ] + ) + def _get_base_path() -> Path: """Return the directory that contains templates/ and static/. @@ -58,8 +84,24 @@ def create_app(exclusion_rules_path: str | None = None) -> Flask: app.config["EXCLUSION_RULES"] = loaded_rules @app.context_processor - def inject_year() -> dict[str, int]: - return {"current_year": datetime.now().year} + def inject_year() -> dict[str, int | str]: + return { + "current_year": datetime.now().year, + "csp_nonce": getattr(g, "csp_nonce", ""), + } + + @app.before_request + def _assign_csp_nonce() -> None: + g.csp_nonce = secrets.token_urlsafe(16) + + @app.after_request + def _set_content_security_policy(response: Response) -> Response: + content_type = response.content_type or "" + if content_type.startswith("text/html"): + nonce = getattr(g, "csp_nonce", "") + if nonce: + response.headers["Content-Security-Policy"] = build_content_security_policy(nonce) + return response # Register API blueprints app.register_blueprint(workspaces_bp) diff --git a/static/js/app.js b/static/js/app.js index 648bbab..c21ee5d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -36,6 +36,10 @@ function toggleTheme() { // Apply on load document.addEventListener('DOMContentLoaded', () => { applyTheme(getStoredTheme()); + const themeToggle = document.getElementById('theme-toggle'); + if (themeToggle) { + themeToggle.addEventListener('click', toggleTheme); + } }); diff --git a/static/js/download.js b/static/js/download.js index d571118..a4443ce 100644 --- a/static/js/download.js +++ b/static/js/download.js @@ -288,7 +288,7 @@ function copyAllMarkdown() { const md = convertChatToMarkdown(selectedTab, true); navigator.clipboard.writeText(md).then(function() { // Brief feedback (you could show a toast) - const btn = document.querySelector('[onclick="copyAllMarkdown()"]'); + const btn = document.getElementById('btn-copy-all'); if (btn) { const orig = btn.innerHTML; btn.innerHTML = ' Copied!'; diff --git a/static/js/theme-bootstrap.js b/static/js/theme-bootstrap.js new file mode 100644 index 0000000..d247e4b --- /dev/null +++ b/static/js/theme-bootstrap.js @@ -0,0 +1,7 @@ +// Apply stored theme before the stylesheet loads to avoid a dark->light flash. +(function () { + try { + var t = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', t); + } catch (e) {} +})(); diff --git a/templates/base.html b/templates/base.html index 37d1abd..a8bdade 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,14 +6,7 @@