Skip to content

Security

Engineers and reviewers who need to judge whether the product is safe enough for a given event or client, and developers who must not weaken a control by accident.

  • The product is small and has a small attack surface: one HTTPS host (stickers.eventtalks.uz) behind Caddy, one Telegram bot using long polling, and a landing page on a separate platform.
  • Strong points: secrets live only in the server’s bot/.env (mode 600) and in the landing platform’s settings; the admin panel and POST /leads use constant-time comparison with brute-force lockout; a photo is accepted only for a session in which a real Telegram account pressed the consent button.
  • The kiosk API is open to the internet by design. Abuse is bounded by the consent step, the one-pack-per-account limit and the per-job cost ceiling, not by authentication.
  • All rate limiters are in process memory and reset on restart.
  • Everything runs on one shared server next to another project. Isolation is by Unix user and file modes, not by machine.
Asset Where it lives Why it matters
Guest photos and derived images bot/data/sessions, bot/data/debug_sheets on the server; sent to the AI provider Biometric-like personal data
Lead contact data leads table in SQLite; cards in the Telegram leads group; daily backups Personal data of prospects
Telegram bot token Server bot/.env; also the landing platform’s environment, for the fallback channel Full control of the bot: read updates, message guests, post to the group
AI API keys and budget Server bot/.env Direct money loss; a drained key stops the event
Admin panel https://stickers.eventtalks.uz/admin Guest list with names and Telegram ids, reset and retry actions
Leads group Telegram Everyone in it sees lead contacts
Leads shared secret Server bot/.env and the landing platform’s environment Lets the holder create leads
Adversary Capability assumed
Internet scanner Finds the host, probes paths, brute-forces passwords
Form spammer Scripts the landing form or its server action
Curious guest Has a phone, the kiosk QR, browser dev tools; no credentials
Group member who is not a lead admin Sees the leads group, can press buttons and type commands
Holder of a leaked landing secret Can call POST /leads directly with a valid header
Co-tenant on the shared server Runs other services on the same machine under another Unix user
Threat Control Where in code Residual risk
Scanner brute-forces the admin panel HTTP Basic over HTTPS; both user and password compared with secrets.compare_digest; 5 failures per IP in 10 minutes → 429 for 10 minutes; 0.5 s delay per failure; production refuses to start with an empty password admin_panel.py require_admin; config.py validate Single shared account, no second factor. A password shorter than 12 characters only produces a warning. Lockout is per IP and in memory
Another site makes the admin’s browser perform an action (CSRF) Every POST under /admin/api requires the header X-Admin-Action: 1, which a cross-site form cannot add admin_panel.require_action_header None known
Admin pages cached, framed or indexed Cache-Control: no-store, X-Robots-Tag: noindex, X-Frame-Options: DENY, Referrer-Policy: no-referrer and a restrictive Content-Security-Policy on every /admin response, including 401 and 429 api.py admin_headers The CSP allows inline scripts and styles, because the panel is one self-contained HTML file
Admin panel leaks photos The panel API returns a fixed list of fields per row. Photos, file paths, the appearance passport and debug sheets are never served admin_panel._public_row The stylised anchor portrait and QA summary are served when ADMIN_SHOW_ANCHOR=1 (the default)
Scanner guesses the leads secret Constant-time compare; 5 wrong secrets per caller IP in 10 minutes → 429; endpoint answers 404 when the module is off; production refuses a secret shorter than 24 characters leads_api.py; services/leads.py secret_ok The lockout key is the caller IP. A landing deployed with a wrong secret locks itself out and then shows visitors “too many requests” (see Lead submission)
Spammer floods the form Landing: honeypot field, 3 s minimum fill time, schema validation, 5 per IP per 10 minutes. Server: 5 per visitor hash per 10 minutes, 60 per hour in total with an admin alert submitRequest.ts, rateLimit.ts, leads_api.create_lead Landing counters live in a serverless instance and reset on a cold start. There is no CAPTCHA. A flood of 60 per hour blocks genuine requests for the rest of the hour
Leaked landing secret is used to inject leads Hourly cap (60) with alert; field validation; 16 KB body cap; rotation is a change of one variable on each side leads_api.create_lead The holder chooses the client_ip value, so the per-visitor limit does not bind them; only the hourly cap does. Rotation needs a server restart and a landing redeploy
Visitor IP addresses collected The raw IP is never stored. Only HMAC-SHA256(LEADS_IP_SALT, ip), truncated to 32 hex characters. Production refuses to start without the salt services/leads.ip_hash Whoever has the salt can test candidate addresses against a hash
Lead text breaks the card or injects markup All visitor text passes html.escape before it enters the HTML card. The fallback message is plain text with an explicit link entity. CSV export prefixes cells starting with =, +, -, @ with an apostrophe services/leads.card_text, export_csv; requestMessage.ts None known
Group member changes lead statuses or reads the export Buttons and commands check LEADS_ADMIN_IDS. Others get “No access” or silence. The CSV goes to the caller’s private chat, never to the group handlers/leads.py Every group member, and any other bot that can read the group, still sees the cards with contacts. LEADS_CARD_PII=0 hides contacts on the card
Guest command typed in a group reaches the guest flow Guest, admin and maintenance routers accept private chats only handlers/start.py, consent.py, admin.py, maintenance.py None known
Photo uploaded without consent POST …/photo requires status template_chosen; that state is reachable only after the bot moved the session to consented for the same Telegram account db.set_session_consented, db.set_session_template, db.take_photo_and_enqueue None known
Malicious upload 15 MB cap in the API and 20 MB in Caddy; the file must decode as an image, be at least 256 px, and is re-encoded as JPEG, which also strips EXIF api._normalize_photo Depends on the image library’s decoders
Someone burns the AI budget through the open kiosk API One pack per Telegram account (db._quota); per-job ceiling JOB_MAX_COST_USD; global parallelism cap; daily image-request warning; automatic pause on 402 worker.py, services/usage.py See “Kiosk API is open” below
Guest takes over another guest’s session The session token is 12 random bytes (secrets.token_urlsafe(12)); a session linked to one account cannot be linked to another api.create_session, db.link_session_to_user Whoever scans a displayed QR first owns that session. It is their own consent and their own pack
Test scripts spend the event’s key With EVENT_MODE=1 paid test tools refuse to run on the production key; production refuses to start if the test key equals the production key services/event_guard.py Applies only to tools that call the guard
Two bot processes share one token and one queue OS-level exclusive file lock; the second process exits instance_lock.py The lock is per machine. A second copy on another machine is not detected
Secrets or guest data leak through a deploy The deploy archive is git archive HEAD and is scanned against a deny list before upload deploy/push.ps1 A secret committed under an unlisted file name would pass
Secrets readable on the server bot/.env owned by the service user, mode 600, re-applied on every deploy; data directory mode 750; backups directory mode 700, files 600 deploy/push.ps1, deploy/update.sh, deploy/backup.sh root on a shared server reads everything
Service process is compromised Dedicated system user without a login shell; NoNewPrivileges, ProtectSystem=full, ProtectHome, PrivateTmp, application tree read-only except bot/data and logs, UMask=0027 sticker-api.service, sticker-bot.service No network or system-call filtering. The process can read bot/.env and all guest data
Network exposure of the server ufw denies inbound except ports 22, 80 and 443; fail2ban on SSH (5 tries in 10 minutes → 1 hour ban); the API listens on the loopback interface only deploy/setup.sh SSH hardening beyond this is not in the repository
Transport and browser attacks on the kiosk host Caddy terminates TLS with automatic certificates and adds HSTS, nosniff, X-Frame-Options: DENY, Referrer-Policy, a Permissions-Policy that allows the camera for the same origin only, and removes the Server header deploy/caddy/stickers.caddy No CSP on kiosk pages
SQL injection All values are bound parameters. String formatting is used only for fixed column lists and status tuples defined in code db/db.py None known
Landing leaks the secret or the bot token to the browser Both are read only inside the server action; none uses the NEXT_PUBLIC_ prefix; a test asserts it deliverRequest.ts, deliverRequest.test.ts The landing platform’s environment holds a bot token. The in-repo notes say the fallback uses the product’s own bot, so a leak there exposes the main bot

POST /session, the QR, the status poll, outfit choice and photo upload have no authentication and no rate limit. The kiosk is an ordinary web page, so it cannot hold a secret.

  • Anyone can create session rows without limit. Each row is small; nothing prunes them.
  • Anyone with a Telegram account can go through consent with that account and upload an arbitrary image through the API, without standing at the tablet. They get one pack per account. The cost is bounded by JOB_MAX_COST_USD per job, but not across many accounts.
  • GET /health is public and returns the code version, render mode, environment name, number of active jobs and the maintenance source.

The in-repo deploy notes name the mitigation if this becomes a problem: put HTTP Basic authentication in front of the kiosk paths in Caddy.

The admin lockout, the wrong-secret lockout, the per-visitor and hourly lead limits and the alert cooldowns are all Python dictionaries in one process. A restart clears them. They are correct only while the API runs as a single uvicorn worker, which is what the unit file starts.

  • The API listens on a loopback port. Any local process, including the co-tenant’s, can call it directly and skip Caddy.
  • The API trusts forwarded-address headers from the loopback interface. A local caller can therefore present any client address and sidestep the per-IP lockouts.
  • Memory and CPU are shared. The units carry MemoryHigh, MemoryMax and a lower CPUWeight so the neighbour is not starved; see Performance.
  • A provider snapshot restore replaces the whole machine for both projects.

Lead cards, fallback messages, admin alerts (which include guest Telegram ids) and CSV exports sent to private chats all live on Telegram’s servers once sent. The product can edit its own cards but cannot recall an export.

No dependency or secret scanning in the repositories

Section titled “No dependency or secret scanning in the repositories”

Neither repository contains CI configuration. Dependency pinning exists (bot/requirements.txt holds exact versions), but nothing automatically checks for known vulnerabilities or committed secrets.

  • Privacy — what personal data exists and how long it lives.
  • Secrets — where each secret is stored and how to rotate it.
  • Reliability — the same limits seen as failure handling.
  • Sticker API component — endpoints and their checks.
  • Known gaps — the tracked list of open items.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.