Skip to content

Security

Mohammad-Hasan-Kaman edited this page Oct 20, 2026 · 2 revisions

Security

This page describes the security controls that are actually implemented in the code — what each one does, and where its limits are. It also states plainly what the system does not protect against.


The threat model in one paragraph

The widget is a public chat endpoint on someone else's website. Anyone can call it. The sensitive things are: each clinic's API key, the admin panel, the conversation data of visitors, and admin accounts. The controls below cover each of these in turn.


1. Panel authentication

Password hashing — PBKDF2-HMAC-SHA256

def hash_password(password: str) -> str:
    salt = secrets.token_hex(16)
    dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode(), 100_000)
    return f"{salt}${dk.hex()}"
  • 100,000 iterations of PBKDF2-HMAC-SHA256.
  • A random 16-byte salt per password, stored in the same column.
  • The stored format is salt$hex_digest — both halves live in one column.

Verification is done in constant time:

def verify_password(password: str, stored: str) -> bool:
    try:
        salt, digest = stored.split("$", 1)
    except ValueError:
        return False
    dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode(), 100_000)
    return hmac.compare_digest(dk.hex(), digest)

hmac.compare_digest is used instead of == — a plain string comparison leaks the position of the first differing byte through its timing.

A malformed stored value raises ValueError from the same split, which is caught and turned into False. A corrupt hash row can never authenticate anyone.

Session cookie — a signed, timestamped token

_serializer = URLSafeTimedSerializer(SECRET_KEY)
COOKIE_NAME = "panel_session"

def make_session_cookie(user: dict) -> str:
    return _serializer.dumps({"uid": user["id"], "role": user["role"], "tid": user["tenant_id"]})

itsdangerous.URLSafeTimedSerializer produces a signed payload with an embedded timestamp. It is not encrypted — anyone can read its contents — but without the secret key it cannot be modified.

The cookie is set with these flags:

resp.set_cookie(
    COOKIE_NAME, make_session_cookie(user), max_age=ADMIN_SESSION_TTL,
    httponly=True, samesite="lax", secure=secure,
)
Flag Value Why
httponly True JavaScript cannot read the cookie — an XSS bug cannot steal the session.
samesite lax The cookie is not sent on cross-site POSTs, which closes most CSRF.
secure from COOKIE_SECURE Set to 1 behind HTTPS. The default is 0 — see the checklist.
max_age 4 hours ADMIN_SESSION_TTL.

There is no server-side session table. The cookie carries the user id, the role and the tenant id. But the role inside the cookie is not trusted on its own — the user row is re-read from the database on every request:

def get_current_user(request: Request) -> dict | None:
    ...
    data = _serializer.loads(cookie, max_age=ADMIN_SESSION_TTL)
    ...
    row = conn.execute(
        "SELECT id, username, role, tenant_id, active FROM panel_users WHERE id=?", (data["uid"],)
    ).fetchone()
    if not row or not row[4]:
        return None

That is what makes deactivating an admin account take effect immediately — the cookie still exists, but the account row is either gone or has active = 0, so the request is treated as anonymous. The max_age check is what expires the cookie after four hours; SignatureExpired is caught and None is returned.

Roles

Role Clinic scope
super_admin tenant_id = NULL — every clinic.
admin Bound to exactly one clinic.

Three guard functions:

def require_user(request):   # any signed-in account
def require_super(request):  # super_admin only
def require_admin(request):  # admin or super_admin

require_user raises a 303 with a Location: /admin/login header — a redirect rather than a 401 page, so the browser lands on the login form.

require_super and require_admin raise a 403 with a Persian message.

Super admins are blocked from clinic-scoped pages. A super_admin requesting one clinic's page gets a 403, because those pages assume user["tenant_id"] is a real clinic.


2. Login attempt limit (anti brute-force)

def _login_allowed(ip: str) -> bool:
    now = _time.time()
    if len(_login_hits) > _LOGIN_MAX_KEYS:
        cutoff = now - 300
        for k in [k for k, v in _login_hits.items() if not v or v[-1] < cutoff]:
            _login_hits.pop(k, None)
    hits = _login_hits.get(ip, [])
    hits = [t for t in hits if now - t < 300]
    _login_hits[ip] = hits
    if len(hits) >= 10:
        return False
    hits.append(now)
    return True
  • 10 attempts per 5 minutes per IP.
  • The eleventh attempt gets a 429 with تلاش‌های ورود بیش از حد مجاز. ۵ دقیقه صبر کنید. ("Too many login attempts. Wait 5 minutes.")
  • The counter is pruned once it passes 5,000 keys.
  • A wrong username and a wrong password give the same message and the same status — 401 with نام کاربری یا رمز عبور نادرست است. ("Incorrect username or password.") — so the form does not reveal whether a username exists at all.

This counter lives in the memory of the process it runs in. It resets on restart, and with more than one worker each worker keeps its own counter. That is the same single-worker limitation the chat rate limit has.


3. Chat API rate limit

_RATE_LIMIT = 30
_RATE_WINDOW = 60

30 requests per 60 seconds, keyed on the client IP:

Endpoint Key
POST /api/chat/session {ip}:session
POST /api/chat/message {ip}:{token[:12]}

The message key combines the IP with the first 12 characters of the session token, so one visitor cannot lock out another behind a shared NAT — the limit applies per session, and the token prefix keeps the key short.

Exceeding the limit returns 429 with درخواست بیش از حد مجاز. لطفاً کمی صبر کنید. ("Too many requests. Please wait a moment.")

The implementation is a defaultdict(deque) of timestamps. A background sweep runs at most once per window and removes expired keys, and the total number of keys is capped at 10,000 — when the cap is reached the oldest key is evicted so the memory stays bounded.

This is a fairness control, not a security control. It lives in process memory, so each worker has its own and a restart clears it.


4. The API key

Every chat call carries the clinic's API key:

class SessionReq(BaseModel):
    api_key: str = Field(max_length=128)
def _resolve_tenant(api_key: str, request: Request) -> dict:
    tenant = tenants_mod.get_tenant_by_key(api_key or "")
    if not tenant:
        raise HTTPException(status_code=401, detail="کلید API نامعتبر است.")
  • An unknown key → 401.
  • Keys are generated as "nk_" + secrets.token_hex(16) — 128 bits of entropy from the OS CSPRNG.
  • A key can be regenerated from the super-admin panel; the previous key stops working immediately.
  • The key is stored in plain text in tenants — it has to be comparable, and in practice it is more of a clinic identifier than a secret.

The key is public by construction. It sits in the <script> tag of the client's site, so anyone who can view the page source can read it. What the key does not give is access to the panel or to other clinics — the panel uses a cookie, and the clinic scope comes from the key's own row.


5. Domain restriction

Each clinic has an allowed_domains list. That list is enforced server-side on every API call:

def _host_allowed(host: str, domains: list[str]) -> bool:
    host = host.strip().lower().rstrip(".")
    for d in domains:
        d = d.strip().lower().rstrip(".")
        if not d:
            continue
        if host == d or host.endswith("." + d):
            return True
    return False

The host comes from the Origin header, and falls back to Referer:

origin = request.headers.get("origin") or request.headers.get("referer") or ""
host = urlparse(origin.lower()).hostname or ""
if host and not _host_allowed(host, domains):
    raise HTTPException(status_code=403, detail="دامنه مجاز نیست.")

Three things worth knowing:

  • Exact host match or a subdomain only. client.com matches client.com and www.client.com. It does not match notclient.com, and it does not match client.com.evil.net. A plain substring check would have matched both.
  • An empty Origin header is not rejected. The check only runs when host is non-empty — so a non-browser client that sends no Origin is not blocked by this rule. This is a browser domain allow-list, not an authentication mechanism.
  • An empty allowed_domains list skips the check entirely — the clinic is unrestricted. That is the default for a new clinic.

This is a second layer next to CORS. CORS stops the browser from reading the response; this stops a server from serving one clinic's chat to an unapproved host.


6. Response headers

@app.middleware("http")
async def security_headers(request, call_next):
    resp = await call_next(request)
    resp.headers["X-Content-Type-Options"] = "nosniff"
    if request.url.path != "/widget":
        resp.headers["X-Frame-Options"] = "SAMEORIGIN"
    resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    return resp
Header Value Effect
X-Content-Type-Options nosniff The browser does not guess the content type — it will not turn a response into something executable.
X-Frame-Options SAMEORIGIN The page may not be framed by another origin. /widget is the exception.
Referrer-Policy strict-origin-when-cross-origin Cross-origin requests send only the origin, not the full path.

There is no Content-Security-Policy header. Frame-embedding control is X-Frame-Options alone, and /widget has to be exempt from it or it could not be embedded on a client's site at all.

There is no Strict-Transport-Security header either. Enforcing HTTPS is left to the reverse proxy.


7. CORS

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type"],
    allow_credentials=True,
)

CORS_ORIGINS comes from the environment as a comma-separated list and defaults to http://localhost:8000. allow_credentials=True is required for the panel cookie, and for exactly that reason allow_origins must not be * — browsers reject that combination, and a wildcard would mean "any site may send authenticated requests".

Only GET and POST are allowed, and only the Content-Type header.


8. The postMessage boundary

The widget iframe talks to the host page over postMessage. The host side validates the origin:

window.addEventListener("message", function (e) {
  if (e.origin !== origin) return;
  ...
});

A message from any other origin is ignored. The check is a strict string comparison against the origin, not startsWith — so https://widget.example.com.evil.net does not pass it.

The iframe is also sandboxed with two different origins: the chat document cannot reach into the host page's DOM.


9. Data minimisation

  • No visitor data ever leaves the server. No external AI API, no outside LLM, no analytics script, no tracking pixel. The whole reasoning path is local — the README calls it "Fully local AI" and it genuinely is.
  • Conversation text is stored in widget_messages, and the hourly GC removes messages older than 30 days along with orphaned messages.
  • Requests are never deleted automatically — user_requests is a business record, and deleting one is a deliberate act by the operator.
  • api_key and password_hash are never returned by any endpoint; the panel shows the key in the dashboard because the operator needs it to build the embed code.
  • The login page returns no hint about which part of the credentials was wrong.

10. What is not implemented

Being explicit about gaps matters more than the feature list.

Gap Detail
No Content-Security-Policy Frame-embedding control is X-Frame-Options alone.
No HSTS header TLS enforcement is delegated to Nginx.
Rate limit lives in process memory It resets on restart and each worker has its own. --workers 1 is mandatory.
No account lockout The login limit is per-IP, not per-account. A distributed attack is not stopped.
No 2FA The panel has passwords only.
No panel audit log Nothing records who changed a flow or exported leads.
The API key is readable in page source It is more a clinic identifier than a secret. Rotating the key is the answer.
No encryption at rest The SQLite files are plain. Protect them with disk encryption and filesystem permissions.
XSS is only half-covered The clinic name and flow text are HTML-escaped when substituted, and the widget renders its own HTML from a small controlled set — but there is no CSP as a second line of defence.
The Origin header can be absent The domain allow-list does not apply to a client that sends no Origin.

Production checklist

  • SECRET_KEY is random — python -c "import secrets; print(secrets.token_hex(32))"
  • COOKIE_SECURE=1 and the site is behind HTTPS
  • CORS_ORIGINS lists only real domains — never *
  • Both admin passwords are long, random, and different from each other
  • The passwords were changed in the panel after the first boot
  • allowed_domains is set for every clinic
  • The panel is not reachable on a public port
  • data/ is backed up and the backup files are not world-readable
  • .env is not tracked in git

Related pages

Clone this wiki locally