Operability: - Correlation IDs end-to-end: shared dispatch_id between log lines and EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths) and a new X-Request-Id middleware that normalizes inbound ids and binds request_id into log context. - dispatch_summary block merged into EventLog.details: per-target success/failure counts plus Telegram media delivered/skipped/failed and truncated error lists, so partial outcomes surface in the UI. - Diagnostic mode: admin can flip one module to DEBUG for a bounded window with auto-revert (in-memory only; setup_logging() resets on boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints plus DiagnosticsCassette UI on the settings page. Telegram: - Per-receiver options: disable_notification (silent send) and message_thread_id (forum-topic routing), wired through the dispatcher via a ContextVar so all four send sites (sendMessage / sendPhoto-Video- Document / sendMediaGroup / cache-hit POST) pick them up. - send_large_videos_as_documents target setting: bypass the 50 MB sendVideo cap by falling back to sendDocument for oversized videos. - sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES, 45 MB) with per-item fallback on chunk failure so a stale file_id no longer silently drops a cached asset. Tests: - New: diagnostic_mode, dispatch_summary, request_correlation, telegram_media_group_partial, telegram_per_send_options. Docs: - .claude/reviews/: six-axis production-readiness review of v0.8.1. - .claude/docs/functional-review-2026-05-28.md: focused review of Telegram/Immich/logging subsystems.
30 KiB
Security Review — notify-bridge v0.8.1
Reviewer: security-reviewer (Opus 4.7) — 2026-05-22
Branch: master @ a20635a
Scope: packages/server, packages/core, frontend/src, Dockerfile, docker-compose.yml, .gitea/workflows/, env handling.
Executive Summary
- Overall posture is strong. The project applies many non-obvious controls correctly: Jinja2
SandboxedEnvironmenton every render path;bcryptwith a 72-byte length guard and constant-time login (dummy hash on missing user); JWT withtoken_versionrevocation; SSRF guard with CGNAT, IPv4-mapped-IPv6 unwrapping, and aPinnedResolverthat defeats DNS rebinding; secret-masking log filter; path-traversal-safe backup file resolver; security headers + CSP; non-root Docker user; requiredSECRET_KEY>= 32 chars with a rejection list; non-default Telegram webhook secret enforced; HMAC signature checks on Gitea/Generic webhooks; provider-config secret masking on GET; ownership checks (get_owned_entity) on every parameterised route I sampled. - HIGH — Home Assistant
access_tokenis not masked. It is stored inprovider.config, never added to the mask list in_provider_response, never added to the placeholder-drop list inupdate_provider. Any logged-in user canGET /api/providers/{id}and read their HA token in cleartext, and a partial save will wipe it. Trivial fix. - HIGH — Secrets at rest are plaintext. Telegram bot tokens (
telegram_bot.token), provider configs containingapi_key/api_token/webhook_secret/access_token/SMTP passwords, and email-bot SMTP passwords are stored unencrypted in SQLite. Disk theft, an unrelated read primitive, or any backup leak exposes all credentials. The masking on the API is good UX, but the DB itself has no encryption-at-rest. The exported JSON backup respects asecrets_modeflag (good) but the live DB does not. - MEDIUM — Template-preview endpoints bypass the timeout/size watchdog.
template_configs.preview_config,template_configs.preview_raw,command_template_configs.preview_raw, andnotifier.send_test_template_notificationconstruct freshSandboxedEnvironment(autoescape=False)instances and call.render(...)directly. The hardened helperrender_template()(timeout, source cap, output cap, autoescape) is bypassed. A logged-in user can wedge a worker thread with{% for i in range(10**8) %}x{% endfor %}. Single-tenant deployment limits the blast radius, but the renderer should be the single chokepoint. - MEDIUM — Login rate limit is per-IP only.
POST /api/auth/login @ 5/minkeys onget_remote_address. An attacker behind a proxy / NAT, or one that rotates source IPs (cheap on residential / cloud), trivially bypasses it. There is no per-username lockout, no exponential backoff, no captcha. Combined with no MFA, this leaves the admin account vulnerable to a slow online dictionary attack from a single password (8-char minimum, no complexity requirement). - LOW / INFO — Several smaller findings: webhook payload logs persist source payload (now with key-level redaction, but the redactor is name-based and will miss high-entropy secret values in non-obvious keys); no replay protection on inbound webhooks (no nonce/timestamp window); the
/api/auth/setup3/min limit + JWT issuance race window is hardened with a transaction count guard (good), but the dummy bcrypt hash literal used for timing-equalisation is malformed andbcrypt.checkpwreturnsFalseviaValueError— the swallowed exception still equalises timing, but a maintainer could regress this; CSP allowsscript-src 'unsafe-inline'(necessary for SvelteKit hydration, acceptable risk acknowledged in code).
Findings
CRITICAL
None found.
HIGH
H-1. Home Assistant access_token leaked in provider GET responses
- CWE: CWE-522 (Insufficiently Protected Credentials), CWE-200 (Exposure of Sensitive Information)
- Files:
packages/server/src/notify_bridge_server/api/providers.py:616-624—_provider_responsemasks("api_key", "api_token", "webhook_secret", "password", "client_secret", "refresh_token")but notaccess_token.packages/server/src/notify_bridge_server/api/providers.py:399-405—update_provideralso omitsaccess_tokenfrom the placeholder-drop list, so the response masking is consistent here, but if you fix one you must fix the other.
- Scenario: Any user authenticated to the bridge (any role) calls
GET /api/providers/{id}for an HA provider they own and the response includesconfig.access_tokenin cleartext. The HA long-lived token grants full control of the user's Home Assistant instance (lights, locks, cameras, scripts, devices). In a multi-user deployment, even within the same admin account, a stolen JWT exfiltrates the HA token; in a single-user deployment, any read primitive (XSS via a future template feature, an MITM on an HTTPS misconfiguration) gives the same result. - Remediation: Add
access_tokento both lists.
# providers.py:_provider_response
for secret_field in (
"api_key", "api_token", "webhook_secret", "password",
"client_secret", "refresh_token", "access_token", # <-- add
):
...
# providers.py:update_provider
for secret_field in (
"api_key", "api_token", "webhook_secret", "password",
"client_secret", "refresh_token", "access_token", # <-- add
):
value = incoming.get(secret_field)
if isinstance(value, str) and value.startswith("***"):
incoming.pop(secret_field, None)
Better still: replace the hand-maintained tuple with a single module-level constant _PROVIDER_SECRET_FIELDS referenced from both call sites, plus a unit test that asserts every field declared on the per-provider Pydantic configs whose name appears in a denylist (token, secret, password, key, credential) is in the set. That prevents the next provider type from re-introducing the same gap.
H-2. Secrets stored in plaintext at rest
- CWE: CWE-312 (Cleartext Storage of Sensitive Information), CWE-256 (Plaintext Storage of a Password)
- Files:
packages/server/src/notify_bridge_server/database/models.py:54-84—TelegramBot.token: strpackages/server/src/notify_bridge_server/database/models.py:87-100—MatrixBot(access_token in config)ServiceProvider.config: dict[str, Any](JSON column) holds Immichapi_key, Giteawebhook_secret+api_token, Google Photosclient_secret+refresh_token, HAaccess_token, etc.EmailBot.smtp_password: str(perapi/email_bots.py:142)
- Scenario: An attacker who can read the SQLite file (compromised host, mis-permissioned backup volume, snapshot artifact in
data_dir/backups/, leaked debug dump) gets every credential the bridge speaks: Telegram bot tokens (full bot control), Immich/Gitea/Planka API keys (read all photos / repos), Google Photos refresh tokens (long-lived, hard to revoke at scale), HA long-lived tokens (smart-home), SMTP passwords. The pre-migrate VACUUM-INTO snapshots (packages/server/src/notify_bridge_server/database/snapshot.py) inherit the same plaintext exposure and live alongside the active DB. - Remediation options, in order of effort:
- Short term: document the threat in
OPERATIONS.md, enforce file-system permissions on/data(the Dockerfile chowns to appuser already, but the host bind-mount must bechmod 700), and ensure backups are encrypted at the storage layer (S3 SSE / Borg / restic). - Better: column-level encryption with a key derived from
NOTIFY_BRIDGE_SECRET_KEY(or a separateNOTIFY_BRIDGE_DB_ENCRYPTION_KEY). Use thecryptographylibrary'sFernetfor each sensitive column; envelope the secret JSON keys, not the whole row, soWHEREclauses and existing migrations keep working. Add a one-shot migration that re-encrypts existing rows. - Best: encrypt with a KMS-backed key (HashiCorp Vault Transit, AWS KMS) and rotate per-secret data keys. This is overkill for a homelab homeserver-style deployment but mandatory if the bridge is ever multi-tenant.
- Short term: document the threat in
- Skeleton for option 2:
# new file packages/server/src/notify_bridge_server/security/secretbox.py
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
def _key() -> bytes:
# Derive a deterministic Fernet key from secret_key. Anyone with secret_key
# can decrypt — same threat model as JWT signing — but anyone with the DB
# alone cannot.
import base64, hashlib
h = hashlib.sha256(settings.secret_key.encode()).digest()
return base64.urlsafe_b64encode(h)
_fernet = Fernet(_key())
def encrypt_secret(plaintext: str) -> str:
return _fernet.encrypt(plaintext.encode()).decode()
def decrypt_secret(ciphertext: str) -> str:
return _fernet.decrypt(ciphertext.encode()).decode()
Apply at write time in update_provider / create_provider, decrypt at read time inside make_immich_provider, make_gitea_provider, the Telegram client constructor, etc. Add a migration that scans every ServiceProvider.config JSON and re-encrypts the listed keys in place.
MEDIUM
M-1. Template preview endpoints skip the renderer watchdog
- CWE: CWE-400 (Uncontrolled Resource Consumption), CWE-1333 (Inefficient Regular Expression Complexity — analogous)
- Files:
packages/server/src/notify_bridge_server/api/template_configs.py:608-613—preview_configcallsSandboxedEnvironment(autoescape=False).from_string(template_body).render(...)directly.packages/server/src/notify_bridge_server/api/slot_helpers.py:72-90—render_template_preview(used by/preview-rawfor both notification and command templates).packages/server/src/notify_bridge_server/services/notifier.py:494-499—send_test_template_notification.- The hardened helper
packages/core/src/notify_bridge_core/templates/renderer.py:48-108(with timeout, length caps, output cap) is not used here.
- Scenario: An authenticated admin submits
{% for i in range(10**8) %}x{% endfor %}toPOST /api/template-configs/preview-raw. Jinja2 has no built-in timeout. The sandbox blocks attribute access but not CPU. The request blocks the FastAPI event loop's executor thread until the worker oomkills or the client times out. Repeat to DoS the API. - Remediation: Route every render through a single, hardened helper.
# Use the existing core helper consistently
from notify_bridge_core.templates.renderer import render_template
rendered = render_template(template_str, context) # already has timeout + caps
For the strict-undefined two-pass validation in render_template_preview, fold the watchdog into the helper itself rather than skipping it.
M-2. Login rate limit is per-IP only
- CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts)
- Files:
packages/server/src/notify_bridge_server/auth/routes.py:140-157. - Scenario:
@limiter.limit("5/minute")keyed onget_remote_addressgives 5 attempts per source IP per minute = ~7,200/day per IP. An attacker rotating across 10 IPs (cheap cloud, residential proxies, even a Tor exit pool) gets 72,000/day. With the 8-character minimum password and no complexity requirement, a 7-char-and-common password is reachable in days, not centuries. There is no per-username lockout, no captcha, no MFA. - Remediation:
- Add a per-username sliding-window limiter on top of the per-IP one. Use a second
Limiterwhosekey_funcreturns the lower-cased username from the body. Re-check after parsing the body. - Add an exponential lockout: after N consecutive failures for a username, require a cooldown (record in a
LoginFailuretable or in-memory TTLCache). - Document and recommend deploying behind a reverse proxy that adds CAPTCHA / WAF rate-limiting for login (Cloudflare Turnstile is cheap).
- Track and log failed logins (auth-event audit trail) with src IP + username + timestamp.
- Add a per-username sliding-window limiter on top of the per-IP one. Use a second
# Sketch — a second limiter that keys by username from the parsed body.
async def _check_username_quota(username: str) -> None:
# In-memory TTLCache: 10 attempts per username per 15 minutes
if _username_attempts[username] >= 10:
raise HTTPException(429, "Too many attempts for this account")
_username_attempts[username] += 1
M-3. Webhook payload log redactor is keyword-based, misses value-based secrets
- CWE: CWE-532 (Insertion of Sensitive Information into Log File)
- Files:
packages/server/src/notify_bridge_server/api/webhooks.py:326-358. - Scenario:
_redact_sensitive_bodywalks the JSON and redacts values whose keys containtoken,auth,key,secret, etc. A webhook provider that ships secrets under an innocent key (e.g."oauth_state": "ya29.a0...","continuation": "ABCDE...","x_state": "...") leaves the secret in the persisted payload log. The log row is admin-readable and exported in backups. - Remediation: Layer a high-entropy value detector on top of the key matcher (e.g. anything matching
[A-Za-z0-9_\-+/=]{32,}and high Shannon entropy ≥ 3.5). Lower bound: also redact known prefixes (ya29.,xoxb-,ghp_,glpat_,sk-,Bearer).
M-4. Webhook ingestion has no replay protection
- CWE: CWE-294 (Authentication Bypass by Capture-replay)
- Files:
packages/server/src/notify_bridge_server/api/webhooks.py— Gitea/Planka/Generic. - Scenario: An attacker who once intercepts a signed Gitea push event (network downgrade, log leak from a proxy, exfil from the Gitea side) can replay it indefinitely. The HMAC stays valid; the bridge has no nonce / timestamp window / delivery-ID cache. With a webhook that fires
assets_addedit's just noise. With a webhook that triggers an action (planka card-created →/api/actions/{id}/executechained logic), it could be more. - Remediation: For Gitea, store the last N
X-Gitea-DeliveryUUIDs per provider and reject duplicates; cap with a partial unique index. For the generic webhook, add an optionalreplay_window_seconds+ a timestamp-extracting JSONPath in the provider config. Constant-time string compare.
M-5. bcrypt.checkpw dummy-hash literal is malformed
- CWE: CWE-208 (Observable Timing Discrepancy) — partial.
- Files:
packages/server/src/notify_bridge_server/auth/routes.py:147-152. - Scenario: When the username doesn't exist, the code calls
_verify_password(body.password, "$2b$12$" + "a" * 53). That hash is not a real bcrypt hash;bcrypt.checkpwraisesValueErrorwhich_verify_passwordswallows and returnsFalse. The exception path is faster than a real bcrypt verify (no key schedule), so timing of "user does not exist" differs from "user exists, wrong password" — a maintainer changing the swallow behaviour later could regress this entirely. - Remediation: Cache one valid dummy bcrypt hash at module load time so the verify path actually runs the KDF.
_DUMMY_BCRYPT_HASH = bcrypt.hashpw(b"x", bcrypt.gensalt()).decode() # module load
...
password_ok = await _verify_password(
body.password,
user.hashed_password if user else _DUMMY_BCRYPT_HASH,
)
M-6. Setup endpoint relies on User.id != 0 filter — robust but a single typo breaks it
- CWE: CWE-302 (Authentication Bypass) — defence-in-depth.
- Files:
packages/server/src/notify_bridge_server/auth/routes.py:97-119. - Scenario:
POST /api/auth/setupis gated by "no users with id != 0". The__system__sentinel is id=0. If a future migration changes the sentinel id, or theWHEREclause is dropped during a refactor, setup re-opens silently and an internet-reachable bridge would let an attacker claim the admin account. - Remediation: Add a defence-in-depth flag
AppSetting.setup_completed=trueset during the first successful setup, and require it to be unset (in addition to the count check). This bakes the invariant into a single boolean that's easier to audit.
M-7. Anonymous Prometheus metrics endpoint leaks operational data
- CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
- Files:
packages/server/src/notify_bridge_server/api/metrics.py:138-159. - Notes: This is documented and gated by
NOTIFY_BRIDGE_METRICS_ENABLED, and the comment explicitly says scrapers don't authenticate. Acceptable when the API port is firewalled to the scraper. Surface it here as informational so an operator who exposes the API directly to the internet (e.g. via reverse-proxy without an ACL) doesn't accidentally expose dispatch rates, provider names, queue depths. - Remediation: keep the env flag, but additionally allow
metrics_basic_auth_user/metrics_basic_auth_passwordas a soft credential check on the endpoint so a "default enabled, default protected" mode is possible. Document the threat inOPERATIONS.mdnext to the env var.
LOW
L-1. CSP allows 'unsafe-inline' for scripts
- CWE: CWE-1021 (Improper Restriction of Rendered UI Layers or Frames) — adjacent.
- File:
packages/server/src/notify_bridge_server/main.py:186-201. - Notes: Comment explicitly justifies it — SvelteKit static adapter emits an inline bootstrap. Acceptable, but
'strict-dynamic'with a per-page nonce (or moving the bootstrap into a hashed external module) eliminates the gap entirely. Track as INFO unless future XSS-injection paths emerge.
L-2. CSP style-src 'unsafe-inline' allows inline-style XSS payloads
- CWE: CWE-79 (Cross-site Scripting) — defence-in-depth.
- Same file as L-1. Inline styles are not directly executable, but they are a known vector for click-jacking and data-exfil via CSS selectors. Same remediation path: nonce-based CSP.
L-3. frame-ancestors 'none' but no X-Frame-Options: DENY collision (false — it is set)
- INFO only. Both
X-Frame-Options: DENYandframe-ancestors 'none'are set; modern browsers honour CSP, legacy ones honour XFO. Good.
L-4. Webhook _filter_headers allowlist accepts unknown X-* headers
- CWE: CWE-532
- File:
packages/server/src/notify_bridge_server/api/webhooks.py:361-374. - Notes: The filter strips known sensitive headers, then accepts any
X-*. A custom auth header likeX-Custom-Authentication: <token>would slip past the substring check if the name doesn't containauth/token/key/secret/etc. Low risk because the well-known providers we support don't ship such headers, but a misconfigured generic webhook will leave a credential in the log row. - Remediation: invert the policy — explicit allowlist for known-safe
X-*headers (e.g.X-Forwarded-Foris also borderline since it can carry PII).
L-5. external_url setting is not validated against an allow-list
- CWE: CWE-918 (SSRF), CWE-79 (XSS in the rendered Telegram webhook URL).
- File:
packages/server/src/notify_bridge_server/api/app_settings.py:329-339reads,packages/server/src/notify_bridge_server/api/telegram_bots.py:247writes it into the registered Telegram webhook URL. - Notes: An admin can set
external_urlto anything. The value is used to build the URL passed to Telegram insetWebhook. Telegram itself enforces an HTTPS-only allow-list, so the actual risk is bounded. Still — validate scheme + host + that it doesn't include credentials or fragments.
L-6. Bot token GET endpoint is intentional but worth auditing
- File:
packages/server/src/notify_bridge_server/api/telegram_bots.py:148-156. - Notes:
GET /api/telegram-bots/{bot_id}/tokenreturns the full Telegram bot token to the owner. Used by the frontend to construct webhook URLs. Limiting to a single short-lived nonce perregister_bot_webhookflow would be safer than exposing the token directly. Currently INFO; revisit if a multi-user role model lands.
L-7. SQLite journal mode + backup snapshot file permissions
- File:
packages/server/src/notify_bridge_server/database/snapshot.py:60-95. - Notes: Snapshots are written via
VACUUM INTO 'path'. They land indata_dir/backups/with default umask permissions. In the Docker image the dir is owned byappuserand only that user runs the process, so this is fine. On a host bind-mount, an operator who forgets to lock down/dataexposes every credential in every snapshot to anyone with shell access. Document this inOPERATIONS.md.
L-8. No CSRF token on state-changing endpoints
- CWE: CWE-352
- Notes: The API uses
Authorization: Bearer <jwt>exclusively (no cookies). Browsers don't auto-attachAuthorizationheaders cross-origin, so this is not classical CSRF-exploitable. Combined with strict CORS (allow_credentials=True, explicit origin allowlist, wildcard rejected on startup) and theOrigin/Referersame-host check on the backup endpoints, the practical risk is essentially zero. INFO only.
INFO / NEEDS VERIFICATION
N-1. Jinja2 SandboxedEnvironment is the standard sandbox — confirm it covers your threat model
- The sandbox blocks
__class__,__mro__, etc., but it is well-known that Jinja2's sandbox is not a security boundary against a determined attacker who can author templates. The threat model here is "templates are admin-authored, so we trust them but use the sandbox as defence-in-depth"; that is reasonable. Document explicitly inOPERATIONS.mdthat anyone with template-edit permission has effective RCE on the worker thread ({{ foo.__init__.__globals__... }}style escapes have been published in the past; new ones surface periodically). - Verification: run
bandit -r packages/andsafety checkagainst pinned versions ofjinja2>=3.1. Latest CVEs against Jinja2 sandbox: trackCVE-2024-34064and any 2025+ disclosures. As of the review date there is no known unpatched sandbox-escape injinja2>=3.1.4.
N-2. apscheduler<4
- Notes: The pin
apscheduler>=3.10,<4keeps the bridge on the 3.x line, which is in maintenance. No known CVEs as of this review. Track when 4.x stabilises and migrate.
N-3. python-multipart>=0.0.9
- Notes: This package had high-severity bugs prior to 0.0.6. The minimum here is 0.0.9 — good.
N-4. No signed-image / SBOM on the container
- Notes: The
release.ymlworkflow builds and pushes a multi-tag image but does not sign with cosign or emit an SBOM. For an internet-facing deployment, consider addingcosign signagainst the image digest, andsyft packagesto emit an SBOM at release time. INFO only.
N-5. Frontend dependencies are pinned via caret (^) ranges
- Notes:
package.jsonuses^x.y.z. CI buildsnpm cifrompackage-lock.json, so reproducibility is fine at build time. There is nonpm auditstep in.gitea/workflows/build.yml. Addnpm audit --audit-level=highto the frontend build job.
N-6. NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 is a footgun
- File:
packages/core/src/notify_bridge_core/notifications/ssrf.py:39-52. - Notes: When set, the SSRF guard becomes a no-op. The warning at boot is the only mitigation. Acceptable for the documented homelab use-case; document that the env flag must NEVER be set on an internet-reachable instance, and consider refusing to enable it when
cors_allowed_originsresolves to a non-loopback host (defence-in-depth interlock).
N-7. Verify the auth flow at the WebSocket boundary
- File:
packages/core/src/notify_bridge_core/providers/home_assistant/client.py:54-83. - The
_ws_url_from_basecorrectly strips userinfo before connecting and_redactdefangs error messages — verify thatwss://URLs go through SSRF validation (currently the HA URL is validated byAnyHttpUrlat config time but I did not find a call toavalidate_outbound_url_fullon the HA WS connect path; the resolver would not pin a host the validator never saw). - Action: confirm by reading
ha_subscription.pyfor explicit validation, or add a check that callsavalidate_outbound_url_fullagainst the derivedws_url(treatingws/wsslikehttp/httpsfor the block-range check) beforews_connect.
Prioritised Fix List (Top 10)
- HIGH H-1 — Add
access_tokento the secret-mask list inproviders._provider_responseand the placeholder-drop list inproviders.update_provider. Add a regression test that GETs an HA provider and asserts the response does not contain the cleartext token. - HIGH H-2 — Implement column-level encryption for
TelegramBot.token,MatrixBotaccess tokens,EmailBot.smtp_password, and the sensitive keys insideServiceProvider.config. Use Fernet with a key derived fromSECRET_KEY. Write a one-shot migration. - MEDIUM M-1 — Replace the ad-hoc
SandboxedEnvironment(...).render()calls in the four preview/test paths with the single hardenedrender_template()helper that already has timeout + size caps. - MEDIUM M-2 — Add per-username login lockout (TTL cache or DB-backed) on top of the per-IP
5/minute. Log failed login attempts. - MEDIUM M-5 — Replace the malformed dummy bcrypt literal in
login()with a real bcrypt hash computed once at module load so the timing-equalisation actually runs the KDF. - MEDIUM M-3 — Strengthen
_redact_sensitive_bodywith a value-entropy heuristic and well-known token-prefix matching. - MEDIUM M-4 — Add replay protection on Gitea webhooks via the
X-Gitea-Deliveryheader (small table + partial unique index). - MEDIUM M-7 — Make the metrics endpoint require either a flag or a Basic Auth credential; document in
OPERATIONS.mdthat the API port should not be internet-exposed when metrics are on. - MEDIUM M-6 — Add a defence-in-depth
setup_completedboolean inapp_settingand check it in/api/auth/setupin addition to the count. - N-5 — Add
npm audit --audit-level=highto the frontend build job in.gitea/workflows/build.ymlso dependency CVEs land in CI.
What was confirmed safe (worth keeping)
- JWT design: HS256 with
iss/aud/exp/type/sub/ver; refresh/access split;token_versionrevocation on role change, username change, and password change. - bcrypt with 72-byte length guard; CPU-bound work run in a thread.
- SSRF guard with: scheme allowlist, IPv6-mapped-v4 unwrap, CGNAT block, IDN normalisation, async resolver,
PinnedResolverto defeat DNS rebinding. - SQL access goes through SQLModel/SQLAlchemy with bind parameters; the only
f"..."SQL is in DDL (column adds, index creates,VACUUM INTO) using server-controlled identifiers — sampled and clean. - Sandbox is
SandboxedEnvironmenteverywhere a user-controllable template is rendered (six locations checked). - Frontend
{@html}is wrapped insanitizePreview()everywhere (tracking-configs,template-configs,command-template-configs). - Provider config secrets are masked on GET (except H-1).
_resolve_backup_filerejects.., NUL, separators, and enforcesrelative_to(base).- CORS rejects wildcard with credentials at startup; secret_key default values are rejected with a clear error.
- Docker: non-root user,
read_only: true,tmpfs: /tmp,no-new-privileges,cap_drop: ALL, resource limits, healthcheck on/api/ready. - Logging:
SecretMaskingFiltermasks Telegram bot tokens,Authorization,x-api-key,password,secret,access_token,refresh_tokenfrom formatted messages, exception text, and stack traces. - Telegram webhook: secret token mandatory, refused on missing config, opaque
webhook_path_idseparate from bot token. - Inbound generic webhook: refuses
auth_mode="none"unless an explicit acknowledgment field is set; auto-generates a strong secret if missing forbearer_token/hmac_sha256. - Inbound payload size capped at 1 MiB with a streaming check that doesn't trust
Content-Length.
Methodology
- Manual code review of every authentication, authorization, webhook ingestion, template rendering, secret-handling, and outbound HTTP path under
packages/. - Cross-checked CORS / CSP / security headers and rate-limiter configuration in
main.py+auth/routes.py. - Sampled API routes for ownership enforcement (
get_owned_entity/_get_user_provider/_get_user_bot) — all sampled routes apply it; no IDOR found. - Grepped for
Environment(/jinja2.Environment/f"..."SQL /{@html}/subprocess/eval/os.system/ known-bad patterns. - Reviewed CI workflows for secret leakage in env blocks and image-signing posture.
- Reviewed Dockerfile + docker-compose for least-privilege and read-only root.
- No dynamic testing performed; static review only. Run
pytest(already gated in CI) +bandit -r packages/+npm auditin CI to backstop this review.