Skip to content

Commit 5319a90

Browse files
author
crispasr integration
committed
Merge branch 'security-hardening' into main
2 parents ecf9218 + c0f2c2d commit 5319a90

14 files changed

Lines changed: 1024 additions & 185 deletions

README.md

Lines changed: 139 additions & 103 deletions
Large diffs are not rendered by default.

app.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ def initialize_session(request: gr.Request):
642642
"has_sandbox_access": getattr(user, "has_sandbox_access", False),
643643
"umk": umk,
644644
"max_model_size_b": _max_model_b,
645+
"must_change_password": getattr(user, "must_change_password", False),
645646
}
646647

647648
ensure_user_storage_dirs(username)
@@ -720,10 +721,16 @@ def initialize_session(request: gr.Request):
720721
],
721722
)
722723

723-
# Logout: redirect to Gradio's built-in /logout endpoint
724+
# Logout: clear server-side UMK cache, then redirect to Gradio /logout
725+
def _on_logout(user_state):
726+
username = (user_state or {}).get("username")
727+
if username:
728+
from db_ops import clear_cached_umk
729+
clear_cached_umk(username)
730+
724731
logout_btn.click(
725-
fn=None,
726-
inputs=None,
732+
fn=_on_logout,
733+
inputs=[session_state],
727734
outputs=None,
728735
js="() => { window.location.href = '/logout'; }",
729736
)

chat_handlers.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,13 @@ def _handler(args: dict):
356356
if not url.startswith(("http://", "https://")):
357357
return ToolResult(text=f"Invalid URL: {url}", error=True)
358358

359+
# SSRF guard: block private/loopback/metadata IPs
360+
from ssrf_guard import is_safe_url
361+
362+
ok, reason = is_safe_url(url)
363+
if not ok:
364+
return ToolResult(text=f"URL blocked ({reason}): {url}", error=True)
365+
359366
print(f"[TOOLS] 🔬 analyze_image_url | url={url!r}")
360367

361368
# Download image to temp file
@@ -505,8 +512,7 @@ def no_creds_error(*args, **kwargs): # type: ignore[no-untyped-def]
505512
"exchange_contact_edit": no_creds_error,
506513
}
507514

508-
_masked = {k: ("***" if k == "password" else v) for k, v in creds.items()}
509-
logger.info("[EXCH] Credentials loaded for user_id=%s: %s", user_id, _masked)
515+
logger.info("[EXCH] Credentials loaded for user_id=%s: email=%s", user_id, creds.get("email", "?"))
510516

511517
def handle_exchange_list(args: dict) -> str:
512518
logger.debug("[EXCH] exchange_list called with args=%s", args)
@@ -527,10 +533,22 @@ def handle_exchange_send(args: dict) -> str:
527533
"[EXCH] exchange_send called with args=%s",
528534
{k: ("***" if k == "body" else v) for k, v in args.items()},
529535
)
530-
# schema uses 'mailbox' for sender override; function expects 'email'
536+
# Security: force draft=True unless the user explicitly set draft=False.
537+
# This prevents prompt-injection attacks from silently sending emails.
531538
send_args = dict(args)
532539
if "mailbox" in send_args:
533540
send_args["email"] = send_args.pop("mailbox")
541+
if not send_args.get("draft") is False:
542+
# Default to saving as draft for safety
543+
send_args["draft"] = True
544+
result = exchange_cli.send_mail(credentials_dict=creds, **send_args)
545+
logger.info("[EXCH] exchange_send (draft) result: %s", result)
546+
return (
547+
f"{result}\n\n"
548+
"Die E-Mail wurde als ENTWURF gespeichert (Sicherheitsmassnahme). "
549+
"Der Benutzer muss den Entwurf manuell in Outlook senden oder "
550+
"die Anfrage mit draft=false wiederholen."
551+
)
534552
result = exchange_cli.send_mail(credentials_dict=creds, **send_args)
535553
logger.info("[EXCH] exchange_send result: %s", result)
536554
return result
@@ -659,7 +677,15 @@ def run_chat(
659677
"brave_answers", "duckduckgo", "searxng").
660678
inject_date: prepend today's date to the system prompt (default True).
661679
"""
662-
# VALIDATION
680+
# VALIDATION — enforce password rotation before any chat usage
681+
if (user_state or {}).get("must_change_password"):
682+
yield (
683+
"🔒 **Passwortänderung erforderlich**\n\n"
684+
"Ihr Passwort muss geändert werden, bevor Sie den Chat nutzen können.\n"
685+
"Bitte gehen Sie zum Tab **Historie → Passwort ändern**."
686+
)
687+
return
688+
663689
allowed, reason = is_provider_allowed(provider, user_state)
664690
if not allowed:
665691
yield f"⛔ **Zugriff verweigert**\n\n{reason}\n\nBitte wählen Sie einen EU-Provider (Scaleway, Mistral, Nebius)."
@@ -1246,7 +1272,8 @@ def run_chat(
12461272
return
12471273

12481274
logger.exception(f"Chat error with {provider}: {e!s}")
1249-
yield f"🔥 Fehler ({provider}): {e!s}"
1275+
# Sanitize: don't leak internal paths or stack traces to the user
1276+
yield f"🔥 Fehler bei der Kommunikation mit {provider}. Bitte erneut versuchen."
12501277

12511278

12521279
# ==========================================
@@ -1505,7 +1532,7 @@ def prepare_api_payload(content, role, is_active_user_msg=False):
15051532
import traceback
15061533

15071534
traceback.print_exc()
1508-
hist[-1]["content"] = f"🔥 System-Fehler: {e!s}"
1535+
hist[-1]["content"] = "🔥 System-Fehler. Bitte erneut versuchen."
15091536
yield hist
15101537

15111538

config.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,22 @@ def check_storage_health(path, timeout=3):
7373
# API KEYS
7474
# ==========================================
7575
API_KEYS = {
76-
"GLADIA": os.environ.get("GLADIA_API_KEY", "your_key"),
77-
"SCALEWAY": os.environ.get("SCALEWAY_API_KEY", "your_key"),
78-
"NEBIUS": os.environ.get("NEBIUS_API_KEY", "your_key"),
79-
"MISTRAL": os.environ.get("MISTRAL_API_KEY", "your_key"),
80-
"OPENROUTER": os.environ.get("OPENROUTER_API_KEY", "your_key"),
81-
"GROQ": os.environ.get("GROQ_API_KEY", "your_key"),
82-
"POE": os.environ.get("POE_API_KEY", "your_poe_key_here"),
83-
"DEEPGRAM": os.environ.get("DEEPGRAM_API_KEY", "your_key"),
84-
"ASSEMBLYAI": os.environ.get("ASSEMBLYAI_API_KEY", "your_key"),
85-
"OPENAI": os.environ.get("OPENAI_API_KEY", "your_key"),
86-
"COHERE": os.environ.get("COHERE_API_KEY", "your_key"),
87-
"TOGETHER": os.environ.get("TOGETHER_API_KEY", "your_key"),
88-
"OVH": os.environ.get("OVH_API_KEY", "your_key"),
89-
"CEREBRAS": os.environ.get("CEREBRAS_API_KEY", "your_key"),
90-
"GOOGLEAI": os.environ.get("GOOGLEAI_API_KEY", "your_key"),
91-
"ANTHROPIC": os.environ.get("ANTHROPIC_API_KEY", "your_key"),
76+
"GLADIA": os.environ.get("GLADIA_API_KEY", ""),
77+
"SCALEWAY": os.environ.get("SCALEWAY_API_KEY", ""),
78+
"NEBIUS": os.environ.get("NEBIUS_API_KEY", ""),
79+
"MISTRAL": os.environ.get("MISTRAL_API_KEY", ""),
80+
"OPENROUTER": os.environ.get("OPENROUTER_API_KEY", ""),
81+
"GROQ": os.environ.get("GROQ_API_KEY", ""),
82+
"POE": os.environ.get("POE_API_KEY", ""),
83+
"DEEPGRAM": os.environ.get("DEEPGRAM_API_KEY", ""),
84+
"ASSEMBLYAI": os.environ.get("ASSEMBLYAI_API_KEY", ""),
85+
"OPENAI": os.environ.get("OPENAI_API_KEY", ""),
86+
"COHERE": os.environ.get("COHERE_API_KEY", ""),
87+
"TOGETHER": os.environ.get("TOGETHER_API_KEY", ""),
88+
"OVH": os.environ.get("OVH_API_KEY", ""),
89+
"CEREBRAS": os.environ.get("CEREBRAS_API_KEY", ""),
90+
"GOOGLEAI": os.environ.get("GOOGLEAI_API_KEY", ""),
91+
"ANTHROPIC": os.environ.get("ANTHROPIC_API_KEY", ""),
9292
"BFL": os.environ.get("BFL_API_KEY", ""),
9393
"REQUESTY": os.environ.get("REQUESTY_API_KEY", ""),
9494
"LANGDOCK": os.environ.get("LANGDOCK_API_KEY", ""),

crispembed_tool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def run_embed_text(texts: list[str], model: str = "all-MiniLM-L6-v2") -> list[li
250250

251251
import json
252252

253-
cmd = [binary, "-m", model, "--json", "--auto-download"] + list(texts)
253+
cmd = [binary, "-m", model, "--json", "--auto-download", "--"] + list(texts)
254254

255255
try:
256256
proc = subprocess.run( # nosec B603

db_ops.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ def get_cached_umk(username: str):
138138
return _UMK_CACHE.get(username)
139139

140140

141+
def clear_cached_umk(username: str):
142+
"""Remove the cached UMK for *username* on logout."""
143+
with _UMK_LOCK:
144+
_UMK_CACHE.pop(username, None)
145+
146+
141147
def authenticate_user(username, password):
142148
"""
143149
Authenticate user AND create keychain if needed.
@@ -499,6 +505,9 @@ def change_password_secure(user_id, old_password, new_password):
499505

500506
# Standard password update
501507
user.password_hash = hash_password(new_password)
508+
# Clear the must_change_password flag after successful rotation
509+
if hasattr(user, "must_change_password"):
510+
user.must_change_password = False
502511
db.commit()
503512
return True, "✅ Passwort geändert & Key neu verschlüsselt (Daten bleiben sicher)"
504513
except Exception as e:
@@ -629,14 +638,16 @@ def get_decrypted_vision(vision_id: int, user_id: int, user_state=None):
629638
if vis.is_encrypted:
630639
try:
631640
# Decrypt with user key, fallback to global
632-
vis.result = crypto.decrypt_text(str(vis.result), key=umk) # type: ignore[assignment]
641+
orig_result = str(vis.result)
642+
vis.result = crypto.decrypt_text(orig_result, key=umk) # type: ignore[assignment]
633643
if vis.result == "[Decryption Failed]" and umk != crypto.global_key:
634-
vis.result = crypto.decrypt_text(str(vis.result), key=crypto.global_key) # type: ignore[assignment]
644+
vis.result = crypto.decrypt_text(orig_result, key=crypto.global_key) # type: ignore[assignment]
635645

636646
if vis.prompt:
637-
vis.prompt = crypto.decrypt_text(str(vis.prompt), key=umk) # type: ignore[assignment]
638-
if vis.prompt == "[Decryption Failed]":
639-
vis.prompt = crypto.decrypt_text(str(vis.prompt), key=crypto.global_key) # type: ignore[assignment]
647+
orig_prompt = str(vis.prompt)
648+
vis.prompt = crypto.decrypt_text(orig_prompt, key=umk) # type: ignore[assignment]
649+
if vis.prompt == "[Decryption Failed]" and umk != crypto.global_key:
650+
vis.prompt = crypto.decrypt_text(orig_prompt, key=crypto.global_key) # type: ignore[assignment]
640651
except Exception as e:
641652
logger.error(f"Vision decryption failed: {e}")
642653
vis.result = "[Fehler: Entschlüsselung fehlgeschlagen]" # type: ignore[assignment]

gladia.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def load_api_key() -> str:
5353
if not api_key:
5454
print("❌ Error: GLADIA_API_KEY not found in environment or .env file")
5555
sys.exit(1)
56-
debug(f"API key loaded: {api_key[:10]}...")
56+
debug("API key loaded: present")
5757
return api_key
5858

5959

pcloud_dl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ def main():
186186

187187
print(f"[*] Downloading {len(to_download)} files...")
188188
for f in to_download:
189-
path = os.path.join(target_dir, f["name"])
189+
path = os.path.join(target_dir, os.path.basename(f["name"]))
190190
try:
191191
downloader.download_stream(f, path)
192192
except Exception as e:

requirements.txt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ openai>=1.0.0
1313
# HTTP / networking
1414
requests>=2.32.0
1515
httpx>=0.27.0 # async HTTP for dictation_manager.process_crispasr_remote
16-
websockets
17-
certifi
18-
idna
19-
urllib3
20-
chardet
16+
websockets>=16.0
17+
certifi>=2026.5.20
18+
idna>=3.18
19+
urllib3>=2.7.0
20+
chardet>=7.4.3
2121
requests-toolbelt>=0.8.0
2222

2323
# Database
@@ -43,10 +43,10 @@ python-pptx>=0.6.23
4343
lxml>=4.9.0
4444

4545
# Misc
46-
python-dotenv
47-
beautifulsoup4
48-
pydub
49-
yt-dlp
46+
python-dotenv>=1.1.0
47+
beautifulsoup4>=4.14.3
48+
pydub>=0.25.1
49+
yt-dlp>=2026.3.17
5050

5151
# ── ppt-llm presentation generator (pptx_tool.py / pptx_engine) ──
5252
pydantic>=2.0

ssrf_guard.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Shared SSRF guard — validates URLs before server-side fetches.
2+
3+
Rejects non-HTTP(S) schemes, resolves hostnames and blocks
4+
private / loopback / link-local / reserved / metadata addresses.
5+
"""
6+
7+
import ipaddress
8+
import socket
9+
from urllib.parse import urlparse
10+
11+
__all__ = ["is_safe_url"]
12+
13+
14+
def is_safe_url(url: str) -> tuple[bool, str]:
15+
"""Check whether *url* is safe to fetch server-side.
16+
17+
Returns ``(True, "")`` when safe, or ``(False, reason)`` when the
18+
URL must be refused (private IP, bad scheme, etc.).
19+
"""
20+
try:
21+
p = urlparse(url)
22+
except Exception as e:
23+
return False, f"unparseable URL: {e}"
24+
25+
if p.scheme not in ("http", "https"):
26+
return False, f"scheme not allowed: {p.scheme!r}"
27+
28+
host = p.hostname
29+
if not host:
30+
return False, "no hostname"
31+
32+
try:
33+
infos = socket.getaddrinfo(host, None)
34+
except Exception as e:
35+
return False, f"DNS resolution failed: {e}"
36+
37+
for _fam, _t, _p, _c, sockaddr in infos:
38+
ip_str = sockaddr[0]
39+
try:
40+
ip = ipaddress.ip_address(ip_str)
41+
except ValueError:
42+
return False, f"invalid resolved IP: {ip_str}"
43+
if (
44+
ip.is_private
45+
or ip.is_loopback
46+
or ip.is_link_local
47+
or ip.is_reserved
48+
or ip.is_multicast
49+
or ip.is_unspecified
50+
):
51+
return False, f"address blocked (private/loopback/metadata): {ip_str}"
52+
53+
return True, ""

0 commit comments

Comments
 (0)