Repository navigation
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 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.
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.
_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 NoneThat 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.
| 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_adminrequire_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.
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.
_RATE_LIMIT = 30
_RATE_WINDOW = 6030 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.
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.
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 FalseThe 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.commatchesclient.comandwww.client.com. It does not matchnotclient.com, and it does not matchclient.com.evil.net. A plain substring check would have matched both. -
An empty
Originheader is not rejected. The check only runs whenhostis non-empty — so a non-browser client that sends noOriginis not blocked by this rule. This is a browser domain allow-list, not an authentication mechanism. -
An empty
allowed_domainslist 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.
@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.
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.
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.
- 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_requestsis a business record, and deleting one is a deliberate act by the operator. -
api_keyandpassword_hashare 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.
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. |
-
SECRET_KEYis random —python -c "import secrets; print(secrets.token_hex(32))" -
COOKIE_SECURE=1and the site is behind HTTPS -
CORS_ORIGINSlists 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_domainsis 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 -
.envis not tracked in git
- Architecture — where each control lives.
- API Reference — the 401 / 403 / 429 responses.
- Admin Panel — role boundaries in the UI.
- Embedding and Integration — CORS and the domain rule.
- Deployment — environment variables and the reverse proxy.