Harden XSS escaping and encrypt Claude API key at rest

- results.js: add esc() helper and apply to all scan-derived fields
  (name, account_name, folder, source, modified, label, img alt) across
  card/list/preview/subject-lookup/related views. Scan-derived strings can
  carry attacker-controlled markup (e.g. a OneDrive file named with HTML),
  so they must be escaped before innerHTML/attribute embedding. Also escape
  the related-docs onclick JSON to match the delete/redact " pattern.
- cpr_detector._placeholder_svg: escape label/name before embedding — served
  as image/svg+xml via /api/thumb?name=, so an unescaped value was a
  reflected-XSS vector when the URL is opened directly.
- cpr_detector: remove 44-line unreachable duplicate of the face-detection
  body left inside _extract_audio_metadata after its return.
- app_config: encrypt claude_api_key at rest with the machine-keyed Fernet
  (same as the SMTP password); add get_claude_api_key() for decryption.
  Legacy plaintext keys still read and are re-encrypted on next save.
  Update readers in document_scanner.py and routes/app_routes.py.

201 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
StyxX65 2026-06-10 11:06:36 +02:00
parent 1903115e02
commit b6d2915d49
5 changed files with 48 additions and 65 deletions

View File

@ -343,10 +343,17 @@ def save_claude_config(enabled: bool, api_key: "str | None" = None) -> None:
cfg = _load_config() cfg = _load_config()
cfg["claude_ner"] = bool(enabled) cfg["claude_ner"] = bool(enabled)
if api_key is not None: if api_key is not None:
cfg["claude_api_key"] = api_key # Encrypt at rest with the machine-keyed Fernet (same as the SMTP
# password). Falls back to plaintext only if cryptography is missing.
cfg["claude_api_key"] = _encrypt_password(api_key) if api_key else ""
_save_config(cfg) _save_config(cfg)
def get_claude_api_key() -> str:
"""Return the decrypted Claude API key (handles legacy plaintext)."""
return _decrypt_password(_load_config().get("claude_api_key", ""))
# ── Profile storage (15a) ───────────────────────────────────────────────────── # ── Profile storage (15a) ─────────────────────────────────────────────────────
_SETTINGS_PATH = _DATA_DIR / "settings.json" _SETTINGS_PATH = _DATA_DIR / "settings.json"
_SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json" _SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json"

View File

@ -420,49 +420,6 @@ def _extract_audio_metadata(content: bytes, filename: str) -> dict:
return result return result
"""Detect faces in an image file using OpenCV Haar cascades.
Returns the number of faces detected, or 0 if cv2 is unavailable,
the file is not a supported image format, or decoding fails.
Face detection is intentionally strict (minNeighbors=8, min_size=80px) to
reduce false positives on background textures, labels, and artwork.
Haar cascades are tuned for compliance flagging, not exhaustive detection. (#9)
"""
if not SCANNER_OK:
return 0
try:
cv2_mod = getattr(ds, "_get_cv2", None)
if cv2_mod is None:
return 0
cv2, np = ds._get_cv2()
if cv2 is None or np is None:
return 0
except Exception:
return 0
try:
# Decode image bytes → cv2 BGR array
arr = np.frombuffer(content, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
# imdecode failed (e.g. HEIC without codec) — try PIL fallback
if PIL_OK:
try:
from PIL import Image as _PILImg
import io as _io
pil_img = _PILImg.open(_io.BytesIO(content)).convert("RGB")
pil_arr = np.array(pil_img)
img = cv2.cvtColor(pil_arr, cv2.COLOR_RGB2BGR)
except Exception:
return 0
else:
return 0
faces = ds.detect_faces_cv2(img, min_size=80, neighbors=8)
return len(faces)
except Exception:
return 0
def _detect_photo_faces(content: bytes, filename: str) -> int: def _detect_photo_faces(content: bytes, filename: str) -> int:
"""Detect faces in an image file using OpenCV Haar cascades. """Detect faces in an image file using OpenCV Haar cascades.
@ -749,6 +706,11 @@ def _placeholder_svg(ext: str, name: str) -> str:
} }
bg, label = colors.get(ext, ("#9CA3AF", ext.upper().lstrip("."))) bg, label = colors.get(ext, ("#9CA3AF", ext.upper().lstrip(".")))
short = name[:22] + "" if len(name) > 22 else name short = name[:22] + "" if len(name) > 22 else name
# Escape label/name before embedding — served as image/svg+xml, so an
# unescaped value (from the ?name= query param via /api/thumb) would be a
# reflected-XSS vector when the URL is opened directly.
label = _html_esc(label)
short = _html_esc(short)
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="280" height="360"> svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="280" height="360">
<rect width="280" height="360" fill="{bg}"/> <rect width="280" height="360" fill="{bg}"/>
<rect x="20" y="20" width="240" height="280" rx="8" fill="rgba(255,255,255,0.12)"/> <rect x="20" y="20" width="240" height="280" rx="8" fill="rgba(255,255,255,0.12)"/>

View File

@ -243,9 +243,9 @@ def load_nlp():
def _get_claude_ner_config() -> "tuple[bool, str]": def _get_claude_ner_config() -> "tuple[bool, str]":
"""Read Claude NER settings from config.json. Small file — OS-cached.""" """Read Claude NER settings from config.json. Small file — OS-cached."""
try: try:
from app_config import _load_config from app_config import _load_config, get_claude_api_key
cfg = _load_config() cfg = _load_config()
return bool(cfg.get("claude_ner")), str(cfg.get("claude_api_key", "") or "") return bool(cfg.get("claude_ner")), get_claude_api_key()
except Exception: except Exception:
return False, "" return False, ""

View File

@ -99,8 +99,8 @@ def claude_settings():
@bp.route("/api/settings/claude/test", methods=["POST"]) @bp.route("/api/settings/claude/test", methods=["POST"])
def claude_test(): def claude_test():
from app_config import _load_config from app_config import get_claude_api_key
api_key = _load_config().get("claude_api_key", "") api_key = get_claude_api_key()
if not api_key: if not api_key:
return jsonify({"ok": False, "error": "No API key saved"}), 400 return jsonify({"ok": False, "error": "No API key saved"}), 400
try: try:

View File

@ -1,4 +1,18 @@
import { S } from './state.js'; import { S } from './state.js';
// Escape untrusted strings (filenames, account/display names, folders) before
// embedding them in innerHTML / title attributes. Scan-derived values can come
// from attacker-controlled content (e.g. a OneDrive file named with markup),
// so every such field must pass through esc() to prevent stored XSS.
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Cards ───────────────────────────────────────────────────────────────────── // ── Cards ─────────────────────────────────────────────────────────────────────
const SOURCE_BADGES = { const SOURCE_BADGES = {
email: ['📧', 'badge-email', 'Outlook'], email: ['📧', 'badge-email', 'Outlook'],
@ -52,9 +66,9 @@ function appendCard(f) {
card.innerHTML = ` card.innerHTML = `
<div style="font-size:24px; flex-shrink:0">${icon}</div> <div style="font-size:24px; flex-shrink:0">${icon}</div>
<div class="card-info list-info"> <div class="card-info list-info">
<div class="card-name" title="${f.name}">${f.name}</div> <div class="card-name" title="${esc(f.name)}">${esc(f.name)}</div>
<div class="card-meta">${f.size_kb} KB · ${f.modified || ''}${f.folder ? ' · 📂 ' + f.folder : ''}</div> <div class="card-meta">${f.size_kb} KB · ${esc(f.modified || '')}${f.folder ? ' · 📂 ' + esc(f.folder) : ''}</div>
<div class="card-source"><span class="source-badge ${badgeCls}">${label}</span> ${f.source || ''}${f.account_name ? ' · <span class="account-pill" title="' + f.account_name + '">' + (f.user_role === 'student' ? '<span class="role-badge">' + t('role_student','Elev') + '</span>' : f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff','Ansat') + '</span>' : '') + f.account_name + '</span>' : ''}${f.transfer_risk === 'external-recipient' ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0"> Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div> <div class="card-source"><span class="source-badge ${badgeCls}">${esc(label)}</span> ${esc(f.source || '')}${f.account_name ? ' · <span class="account-pill" title="' + esc(f.account_name) + '">' + (f.user_role === 'student' ? '<span class="role-badge">' + t('role_student','Elev') + '</span>' : f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff','Ansat') + '</span>' : '') + esc(f.account_name) + '</span>' : ''}${f.transfer_risk === 'external-recipient' ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0"> Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div>
</div> </div>
<span class="cpr-badge">${f.cpr_count} CPR</span> <span class="cpr-badge">${f.cpr_count} CPR</span>
${f.email_count > 0 ? '<span class="email-badge">' + f.email_count + ' ' + t('m365_badge_emails', 'e-mail') + '</span> ' : ''} ${f.email_count > 0 ? '<span class="email-badge">' + f.email_count + ' ' + t('m365_badge_emails', 'e-mail') + '</span> ' : ''}
@ -65,12 +79,12 @@ function appendCard(f) {
${delBtn}${redactBtn}`; ${delBtn}${redactBtn}`;
} else { } else {
card.innerHTML = ` card.innerHTML = `
<div class="thumb-wrap"><img src="${src}" alt="${f.name}" loading="lazy"></div> <div class="thumb-wrap"><img src="${src}" alt="${esc(f.name)}" loading="lazy"></div>
<div class="card-info"> <div class="card-info">
<div class="card-name" title="${f.name}">${f.name}</div> <div class="card-name" title="${esc(f.name)}">${esc(f.name)}</div>
<div class="card-meta">${f.size_kb} KB · ${f.modified || ''}</div> <div class="card-meta">${f.size_kb} KB · ${esc(f.modified || '')}</div>
${f.folder ? `<div class="card-meta" style="font-size:10px" title="${f.folder}">📂 ${f.folder}</div>` : ''} ${f.folder ? `<div class="card-meta" style="font-size:10px" title="${esc(f.folder)}">📂 ${esc(f.folder)}</div>` : ''}
<div class="card-source"><span class="source-badge ${badgeCls}">${label}</span>${f.account_name ? ' <span class="account-pill" title="' + f.account_name + '">' + (f.user_role === "student" ? '<span class="role-badge">' + t("role_student","Elev") + "</span>" : f.user_role === "staff" ? '<span class="role-badge">' + t("role_staff","Ansat") + "</span>" : "") + f.account_name + '</span>' : ''}${f.transfer_risk === "external-recipient" ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0"> Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div> <div class="card-source"><span class="source-badge ${badgeCls}">${esc(label)}</span>${f.account_name ? ' <span class="account-pill" title="' + esc(f.account_name) + '">' + (f.user_role === "student" ? '<span class="role-badge">' + t("role_student","Elev") + "</span>" : f.user_role === "staff" ? '<span class="role-badge">' + t("role_staff","Ansat") + "</span>" : "") + esc(f.account_name) + '</span>' : ''}${f.transfer_risk === "external-recipient" ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0"> Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div>
<span class="cpr-badge">${f.cpr_count} CPR</span>${f.email_count > 0 ? ' <span class="email-badge">' + f.email_count + ' ' + t('m365_badge_emails', 'e-mail') + '</span>' : ''}${f.phone_count > 0 ? ' <span class="phone-badge">' + f.phone_count + ' ' + t('m365_badge_phones', 'tlf.') + '</span>' : ''}${f.face_count > 0 ? ' <span class="photo-face-badge">' + f.face_count + ' ' + t('m365_badge_faces', f.face_count === 1 ? 'face' : 'faces') + '</span>' : ''}${f.exif && f.exif.gps ? ' <span class="photo-face-badge" style="background:#0a3a5a;color:#7ec8d0">🌍 GPS</span>' : ''}${f._resolved ? ' <span class="resolved-badge"> ' + t('history_resolved_badge', 'Resolved') + '</span>' : ''}${f.overdue ? ' <span class="overdue-badge">🗓 Overdue</span>' : ''} <span class="cpr-badge">${f.cpr_count} CPR</span>${f.email_count > 0 ? ' <span class="email-badge">' + f.email_count + ' ' + t('m365_badge_emails', 'e-mail') + '</span>' : ''}${f.phone_count > 0 ? ' <span class="phone-badge">' + f.phone_count + ' ' + t('m365_badge_phones', 'tlf.') + '</span>' : ''}${f.face_count > 0 ? ' <span class="photo-face-badge">' + f.face_count + ' ' + t('m365_badge_faces', f.face_count === 1 ? 'face' : 'faces') + '</span>' : ''}${f.exif && f.exif.gps ? ' <span class="photo-face-badge" style="background:#0a3a5a;color:#7ec8d0">🌍 GPS</span>' : ''}${f._resolved ? ' <span class="resolved-badge"> ' + t('history_resolved_badge', 'Resolved') + '</span>' : ''}${f.overdue ? ' <span class="overdue-badge">🗓 Overdue</span>' : ''}
</div> </div>
${delBtn}${redactBtn}`; ${delBtn}${redactBtn}`;
@ -111,10 +125,10 @@ async function openPreview(f) {
loading.textContent = 'Loading preview…'; loading.textContent = 'Loading preview…';
meta.innerHTML = [ meta.innerHTML = [
f.account_name ? `<span style="font-weight:500">👤 ${f.account_name}</span>` : '', f.account_name ? `<span style="font-weight:500">👤 ${esc(f.account_name)}</span>` : '',
f.source ? `<span>${f.source}</span>` : '', f.source ? `<span>${esc(f.source)}</span>` : '',
f.size_kb ? `<span>${f.size_kb} KB</span>` : '', f.size_kb ? `<span>${f.size_kb} KB</span>` : '',
f.modified ? `<span>${f.modified}</span>` : '', f.modified ? `<span>${esc(f.modified)}</span>` : '',
f.cpr_count ? `<span style="color:var(--danger)">${f.cpr_count} CPR</span>` : '', f.cpr_count ? `<span style="color:var(--danger)">${f.cpr_count} CPR</span>` : '',
f.email_count ? `<span style="color:#7ec8f0">${f.email_count} ${t('m365_badge_emails','e-mail')}</span>` : '', f.email_count ? `<span style="color:#7ec8f0">${f.email_count} ${t('m365_badge_emails','e-mail')}</span>` : '',
f.phone_count ? `<span style="color:#7eeac0">${f.phone_count} ${t('m365_badge_phones','tlf.')}</span>` : '', f.phone_count ? `<span style="color:#7eeac0">${f.phone_count} ${t('m365_badge_phones','tlf.')}</span>` : '',
@ -206,11 +220,11 @@ async function _loadRelated(f) {
const rows = items.map(item => { const rows = items.map(item => {
const shared = item.shared_cprs ?? ''; const shared = item.shared_cprs ?? '';
const badge = shared ? `<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--danger);color:#fff;font-weight:500;flex-shrink:0">${shared} CPR</span>` : ''; const badge = shared ? `<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--danger);color:#fff;font-weight:500;flex-shrink:0">${shared} CPR</span>` : '';
const src = item.source ? `<span style="color:var(--muted);font-size:10px;flex-shrink:0">${item.source}</span>` : ''; const src = item.source ? `<span style="color:var(--muted);font-size:10px;flex-shrink:0">${esc(item.source)}</span>` : '';
return `<div onclick="window._openRelated('${item.id.replace(/'/g,"\\'")}',${JSON.stringify(item)})" return `<div onclick="window._openRelated('${item.id.replace(/'/g,"\\'")}',${JSON.stringify(item).replace(/"/g,'&quot;')})"
style="display:flex;align-items:center;gap:6px;padding:4px 0;cursor:pointer;border-radius:4px" style="display:flex;align-items:center;gap:6px;padding:4px 0;cursor:pointer;border-radius:4px"
onmouseover="this.style.background='var(--surface)'" onmouseout="this.style.background=''"> onmouseover="this.style.background='var(--surface)'" onmouseout="this.style.background=''">
<span style="flex:1;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${item.name}">${item.name}</span> <span style="flex:1;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(item.name)}">${esc(item.name)}</span>
${src}${badge} ${src}${badge}
</div>`; </div>`;
}).join(''); }).join('');
@ -351,9 +365,9 @@ async function runSubjectLookup() {
_dsubItems = d.items; _dsubItems = d.items;
resultsEl.innerHTML = d.items.map(item => ` resultsEl.innerHTML = d.items.map(item => `
<div class="dsub-result-row"> <div class="dsub-result-row">
<div class="dsub-result-name" title="${item.name}">${item.name}</div> <div class="dsub-result-name" title="${esc(item.name)}">${esc(item.name)}</div>
<div class="dsub-result-meta">${item.source_type || ""}</div> <div class="dsub-result-meta">${esc(item.source_type || "")}</div>
<div class="dsub-result-meta">${item.modified || ""}</div> <div class="dsub-result-meta">${esc(item.modified || "")}</div>
<div class="dsub-result-meta" style="color:var(--danger)">${item.cpr_count} CPR</div> <div class="dsub-result-meta" style="color:var(--danger)">${item.cpr_count} CPR</div>
</div> </div>
`).join(""); `).join("");