# SPDX-License-Identifier: BUSL-1.1 # Copyright (c) 2024-2026 Ali Sadeghi Aghili """Simple web front-end for the local SQL agent. Usage (from this folder):: py -3.13 app.py create-user alice # prompts for password py -3.13 app.py create-user alice s3cret # password on the command line py -3.13 app.py # dev server on http://127.0.0.1:5000 Users are stored in ``app.db`` (SQLite) with hashed passwords. Every submitted question is logged to the ``logs`` table with its result. """ from __future__ import annotations import getpass import os import re import secrets import sqlite3 import sys import threading import time from pathlib import Path from flask import ( Flask, abort, current_app, flash, redirect, render_template, request, send_from_directory, session, url_for, ) import db import i18n from agent import OUTPUT_DIR, answer_question, principal_for_username sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from core.console import use_utf8_console # noqa: E402 from core.provenance import log_startup_notice # noqa: E402 WEBAPP_DIR = Path(__file__).resolve().parent SECRET_KEY_FILE = WEBAPP_DIR / ".secret_key" def _require_admin_user() -> str: """Resolve the one account permitted to create new users (``/register``). Finding 16: this used to fall back to a literal username baked into source -- a real employee's, published in a repository that was public at audit time (finding 1), naming exactly the one account this app treats as privileged. There is no safe default for "the identity of a privileged account": a guessed or hardcoded one is either wrong (silently breaking a deployment that meant to set its own) or, worse, silently *right* for this one deployment and then copied as boilerplate into the next one's config. An unset ``ADMIN_USER`` is a misconfiguration, not something to guess past, so this raises rather than falling back to anything -- read fresh on every call (not cached in a module-level constant) so a process that reloads this module after fixing the environment variable does not keep failing on a stale value from before the fix. """ admin_user = os.getenv("ADMIN_USER") if not admin_user: raise RuntimeError( "ADMIN_USER is not set. This app requires an explicit " "administrator username -- the only account permitted to " "create new accounts via /register -- with no default. Set " "the ADMIN_USER environment variable before starting the app." ) return admin_user def _flash(text: str, category: str = "message", **kwargs) -> None: """Flash ``text`` translated into the session's current language.""" flash(i18n.translate(text, i18n.get_lang(), **kwargs), category) def _secret_key() -> str: """Env var wins; otherwise persist a random key so sessions survive restarts. Finding 18: the persisted file is restricted to owner-only permissions immediately after being written. This file holds the Flask session- signing key -- anyone else with a shell on the host (another service account, an operator, a compromised process) who can read it can forge a session cookie for any user, including the ``ADMIN_USER`` account, with no password and nothing in the auth logs. Guarded for Windows (``os.name != "nt"``): file mode bits are a POSIX concept and the deployment target this finding describes is a Linux server, but the ``chmod`` call itself must stay unconditional in the source so a Linux deployment gets it regardless of what platform last edited this file. """ env_key = os.getenv("SECRET_KEY") if env_key: return env_key if SECRET_KEY_FILE.exists(): return SECRET_KEY_FILE.read_text().strip() key = secrets.token_hex(32) SECRET_KEY_FILE.write_text(key) if os.name != "nt": os.chmod(SECRET_KEY_FILE, 0o600) return key # --------------------------------------------------------------------------- # Finding 17a: per-IP login throttling # --------------------------------------------------------------------------- # In-process and per-app (see _login_failures' own note for why not # module-level), matching the FastAPI side's own bucketing shape # (api/middleware.py's RateLimitMiddleware) -- a fixed threshold of # consecutive failures per source IP, the account name on the other end # never being part of the key. The published ADMIN_USER account (finding # 16) is exactly the target unlimited guessing would go after; capping # guesses per IP rather than per attempted username also stops the # obvious workaround of guessing many different usernames from one # source. Cleared on a successful login from that IP -- this defends # against a *stranger* guessing, not against an operator who mistyped # their own password several times in a row. # # Round 2 remediation: the counter used to have no expiry at all -- # `_login_failure_counts[ip]` only ever went up, or was reset to zero by a # *successful* login from that IP. An IP that never succeeds (because it # belongs to a shared NAT/proxy/office network the real account holder is # behind, or because the attacker keeps guessing past the real user's # actual password too) stays throttled forever. That turns a control meant # to slow down a guesser into a permanent denial of service against # whoever else is behind that address -- worse than the attack it defends # against, since the attacker only had to spend thirty wrong guesses to # inflict it, and needs no further access to keep it in effect. Each # entry now carries the time its failure window started as well as its # count: a window older than ``_LOGIN_FAILURE_WINDOW_SECONDS`` is treated # as stale and discarded (by the very next check or failure from that # IP) rather than compounding forever, and throttling lifts on its own # once the window rolls past, even with no successful login at all. This # does not weaken the defence against sustained guessing: an attacker who # keeps trying stays throttled for the same reason a stale window is # discarded -- it is exactly the fresh failure that starts the next one. _LOGIN_FAILURE_THRESHOLD = 30 #: How long a throttled IP stays throttled with no further failures, and #: the span of consecutive failures the threshold above is measured over. #: Fifteen minutes is long enough that a scripted guesser gains nothing by #: waiting it out repeatedly, short enough that a real user sharing an #: address with whoever tripped it is not locked out for the rest of the #: day. _LOGIN_FAILURE_WINDOW_SECONDS = 15 * 60 #: ``{ip: (failure_count, window_started_at)}`` -- ``window_started_at`` is #: a ``time.monotonic()`` reading, never wall-clock time, so this is #: immune to the system clock being adjusted backwards mid-window. #: #: Deliberately owned by the Flask app object rather than by this module. #: A process serves exactly one app, so in production the two are the same #: thing -- but a module-level dict is shared by every ``create_app()`` a #: process ever builds, which means one caller's failures follow the next #: app into existence. That surfaced first as a test that passed alone and #: failed in a full run (the throttle test's forty failures left the next #: test's address already locked out), and a test that depends on #: collection order is the visible half of a real defect: anything that #: builds a second app in one process -- a test suite, a WSGI server #: reloading, an embedding harness -- inherits state it never created. #: :func:`_failure_state` reads it off ``current_app`` instead. _LOGIN_FAILURE_STATE_KEY = "_login_failures" def _failure_state() -> tuple[dict[str, tuple[int, float]], threading.Lock]: """The current app's own failure map and the lock guarding it.""" extensions = current_app.extensions.setdefault(_LOGIN_FAILURE_STATE_KEY, {}) if "map" not in extensions: extensions["map"] = {} extensions["lock"] = threading.Lock() return extensions["map"], extensions["lock"] def _client_ip() -> str: return request.remote_addr or "unknown" def _login_throttled(ip: str) -> bool: """Whether *ip* has hit the failure threshold within its current window. A window whose start is more than :data:`_LOGIN_FAILURE_WINDOW_SECONDS` in the past is stale -- the failures it counted no longer describe an ongoing attempt -- and is discarded here rather than only by a future :func:`_record_login_failure` call, so a throttled IP that simply stops trying is not left reporting itself as throttled indefinitely. """ now = time.monotonic() failures, lock = _failure_state() with lock: entry = failures.get(ip) if entry is None: return False count, window_started_at = entry if now - window_started_at >= _LOGIN_FAILURE_WINDOW_SECONDS: del failures[ip] return False return count >= _LOGIN_FAILURE_THRESHOLD def _record_login_failure(ip: str) -> None: """Count one failure for *ip*, starting a fresh window if the last one has expired (see :func:`_login_throttled`) or none exists yet.""" now = time.monotonic() failures, lock = _failure_state() with lock: entry = failures.get(ip) if entry is None or now - entry[1] >= _LOGIN_FAILURE_WINDOW_SECONDS: failures[ip] = (1, now) else: count, window_started_at = entry failures[ip] = (count + 1, window_started_at) def _clear_login_failures(ip: str) -> None: failures, lock = _failure_state() with lock: failures.pop(ip, None) # --------------------------------------------------------------------------- # Finding 17b: CSRF -- a session-bound token, enforced on POST # --------------------------------------------------------------------------- # No new dependency (Flask-WTF is the obvious alternative -- see # tests/security_audit/test_flask_hardening.py's module docstring): a # random token is minted into the session the first time a form that # needs one is rendered, embedded as a hidden field, and compared with # constant-time equality against the form submission. # # Round 2 remediation note: /login used to be listed here as "deliberately # exempt", on the reasoning that no session exists yet to bind a token to. # That reasoning does not hold up -- writing into `session[...]` inside # `_csrf_token()` (called by every template that renders the hidden field, # login.html included) makes Flask send a Set-Cookie for that session on # the GET response, before any credential is ever submitted. The token is # therefore already bound to a real, if pre-authentication, session by the # time a form using it can be filled in -- exactly the "issue a pre-session # token" shape this class of fix takes. Leaving the field in login.html # rendered but unchecked (the state this comment used to describe) was # strictly worse than either enforcing it or removing it: a control that # looks present protects nobody and invites the next reader to assume it # does. login is no longer listed in _CSRF_EXEMPT_ENDPOINTS. # # This does not weaken _LOGIN_FAILURE_THRESHOLD above. A scripted # credential-stuffing client that skips the GET and posts credentials # directly is refused with 400 before the throttle counter is ever # touched, which is a strictly earlier and cheaper refusal, not a bypass # of it -- and a client that does fetch a token first (one GET, reused # across every guess, exactly how a real browser and any minimally # competent script both behave) still hits the same per-IP counter on # every attempt after. What actually changes is that submitting this # specific form now requires a token minted by this server for this # session -- which is precisely what stops a third-party page from # silently auto-submitting a victim's browser into a login it never # intended (the attack this project's own audit calls "login CSRF": an # attacker's page logs the victim into the *attacker's* account, then the # victim's own subsequent input -- files uploaded, data entered -- lands # somewhere the attacker can read it back from). _CSRF_SESSION_KEY = "_csrf_token" _CSRF_EXEMPT_ENDPOINTS = {"static"} def _csrf_token() -> str: token = session.get(_CSRF_SESSION_KEY) if not token: token = secrets.token_hex(16) session[_CSRF_SESSION_KEY] = token return token def create_app() -> Flask: log_startup_notice() db.init_db() admin_user = _require_admin_user() app = Flask(__name__) app.secret_key = _secret_key() app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 8 # 8h login # Finding 17c: cookie hardening. Set explicitly rather than left to # Flask's own defaults or a browser's -- see this module's docstring # in tests/security_audit/test_flask_hardening.py for why relying on # either is "a decision someone else can change". SESSION_COOKIE_SECURE # means the cookie is withheld over plain HTTP: a local dev server run # over http:// will look like login silently does not "stick" -- # documented in webapp/WebApp.md rather than left to be rediscovered. app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Strict", ) @app.context_processor def _inject_auth() -> dict[str, str | None]: return {"current_user": session.get("username"), "admin_user": admin_user} @app.context_processor def _inject_i18n() -> dict: lang = i18n.get_lang() return { "current_lang": lang, "langs": [(code, code.upper()) for code in i18n.LANGS], "_": lambda text, **kwargs: i18n.translate(text, lang, **kwargs), "csrf_token": _csrf_token, } @app.before_request def _check_csrf(): """Reject a token-less POST to a form that needs one (finding 17b). Runs before every view, so a request never reaches a route's own logic (including a route's "redirect to /login if not signed in", which is a 302, not the 400/403 this defence is meant to produce) without a valid token first. See the module-level comment above ``_CSRF_EXEMPT_ENDPOINTS`` for why ``login`` is excluded. """ if request.method != "POST" or request.endpoint in _CSRF_EXEMPT_ENDPOINTS: return None submitted = request.form.get("csrf_token", "") expected = session.get(_CSRF_SESSION_KEY, "") if not expected or not secrets.compare_digest(submitted, expected): abort(400) return None @app.route("/lang/") def set_lang(lang): if lang in i18n.LANGS: session["lang"] = lang referrer = request.referrer or "" if referrer.startswith(request.host_url): return redirect(referrer) return redirect(url_for("index")) @app.route("/login", methods=["GET", "POST"]) def login(): if "username" in session: return redirect(url_for("index")) if request.method == "POST": ip = _client_ip() # Finding 17a: checked BEFORE touching the database at all -- # once an IP is throttled, a guess costs it nothing further to # keep guessing against, which is the point. if _login_throttled(ip): return ("Too many failed login attempts. Try again later.", 429) username = request.form.get("username", "").strip() password = request.form.get("password", "") if db.verify_user(username, password) is None: _record_login_failure(ip) _flash("Invalid username or password.", "error") else: _clear_login_failures(ip) session["username"] = username session.permanent = True return redirect(url_for("index")) return render_template("login.html") @app.route("/register", methods=["GET", "POST"]) def register(): username = session.get("username") if username is None: return redirect(url_for("login")) if username != admin_user: _flash("Only an administrator can create accounts.", "error") return redirect(url_for("index")) if request.method == "POST": new_username = request.form.get("username", "").strip() password = request.form.get("password", "") confirm = request.form.get("confirm", "") if not new_username or not password: _flash("Username and password are required.", "error") elif password != confirm: _flash("Passwords do not match.", "error") else: try: db.create_user(new_username, password) except sqlite3.IntegrityError: _flash("Username '%(name)s' is already taken.", "error", name=new_username) else: _flash("Account '%(name)s' created.", "success", name=new_username) return redirect(url_for("register")) return render_template("register.html", username=username) @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.route("/download/") def download(filename): if "username" not in session: return redirect(url_for("login")) # Only serve files the agent itself generated, never arbitrary paths. if not re.fullmatch(r"output_[0-9]{8}_[0-9]{6}(?:_[0-9]+)?\.csv", filename): abort(404) return send_from_directory(OUTPUT_DIR, filename, as_attachment=True) @app.route("/", methods=["GET", "POST"]) def index(): if "username" not in session: return redirect(url_for("login")) result = None if request.method == "POST": question = request.form.get("question", "").strip() if not question: _flash("Please enter a question.", "error") else: # Finding 13: the caller must identify itself so the column # ACL (appdb.key_store / the admin panel's "ستون‌ها" action) # actually applies to this session -- see agent.py's # principal_for_username and its module-level comment. # Finding 14: the checkbox's own value is honoured exactly # as submitted, never overridden -- answer_question's # default covers only the caller who omits the argument. interpret = request.form.get("interpret") == "on" result = answer_question( question, interpret=interpret, principal=principal_for_username(session["username"]), ) db.log_query( username=session["username"], question=question, status=result["status"], generated_sql=result["sql"], interpretation=result["interpretation"], output_file=result["output_file"], row_count=result["row_count"], error_message=result["error_message"], elapsed_seconds=result["elapsed_seconds"], ) if result["status"] != "SUCCESS": flash(result["error_message"], "error") return render_template( "index.html", username=session["username"], result=result, output_filename=( Path(result["output_file"]).name if result and result["output_file"] else None ), ) return app app = create_app() def _cli_create_user(argv: list[str]) -> int: """create-user [password] — password prompts if omitted.""" if len(argv) < 2: print("Usage: app.py create-user [password]") return 2 username = argv[1].strip() password = argv[2] if len(argv) > 2 else getpass.getpass("Password: ") if not username or not password: print("Username and password must not be empty.") return 2 try: db.create_user(username, password) except Exception as exc: # noqa: BLE001 - sqlite3.IntegrityError and friends print(f"Failed to create user: {exc}") return 1 print(f"User '{username}' created.") return 0 def main() -> int: argv = sys.argv[1:] if argv and argv[0] == "create-user": return _cli_create_user(argv) app.run(host="127.0.0.1", port=5000, debug=False) return 0 if __name__ == "__main__": use_utf8_console() sys.exit(main())