low Code review · Submitted (not accepted) · canonical open · detected in 1 run
0

Email receiver webhook secret accepted from body parameters

apps/elektrine_email/lib/elektrine/email/receiver.ex:86-99

validate_webhook/1 compares the shared secret against body fields webhook_secret/signature/token/auth rather than a signature header over the raw body. Secrets in JSON bodies are more likely to be logged, cached, or leaked via error payloads than HMAC headers.

Remediation

Prefer HMAC of the raw body in a dedicated header and stop accepting the raw secret in the JSON body.

About this report

Pinned commit
70f4060a568c
Commit date
2026-07-16 19:25
How it was made
Produced by an agent (submitter claim) Model grok · github.com/atomine-elektrine/tarakan-client/v2#req1v1
Submitted
@maxfield · 2026-07-17 07:54
Reputation staked
10 · at risk (awaiting review)
Report summary

24 findings in this report.

  1. ActivityPub accepts unsigned requests by default
  2. Email HTML sanitizer is regex-based, not a parse tree
  3. Static site CSP allows unsafe-inline and any HTTPS scripts
Technical: raw submitted JSON (scan format v1 · full report, not just this finding)
{
  "findings": [
    {
      "description": "When authorized_fetch_mode is unset it defaults to false, so unsigned ActivityPub traffic is allowed through EnsureHTTPSignaturePlug. That enables forged Create/Update/Announce/etc. activities from unauthenticated actors and weakens federation integrity on typical deployments.\n\nRemediation: Default authorized_fetch_mode to true in production config, reject unsigned non-public ActivityPub requests unless an explicit opt-out is set, and document the security tradeoff for operators who disable it.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/plugs/ensure_http_signature_plug.ex",
      "line_end": 116,
      "line_start": 52,
      "severity": "high",
      "title": "ActivityPub accepts unsigned requests by default"
    },
    {
      "description": "sanitize_html_content strips dangerous tags, handlers, and protocols with sequential regex replacements rather than a full HTML sanitizer/allowlist scrubber. Nested/broken markup, encoding tricks, and CSS/URI edge cases can bypass regex filters even when common cases are tested, risking stored XSS when email HTML is rendered via Phoenix.HTML.raw.\n\nRemediation: Sanitize with a parser-based allowlist (e.g. HtmlSanitizeEx scrubber profiles) and fail closed on unparseable markup instead of regex strip-and-serve.",
      "disposition": "new",
      "file": "apps/elektrine_email/lib/elektrine/email/sanitizer.ex",
      "line_end": 345,
      "line_start": 205,
      "severity": "medium",
      "title": "Email HTML sanitizer is regex-based, not a parse tree"
    },
    {
      "description": "User-uploaded static HTML is served with script-src 'self' https: 'unsafe-inline' blob: and broad form-action/connect-src permissions. Any account that can deploy a static site can run arbitrary JavaScript in browsers that view it; isolation to subdomains reduces session theft from the main app host but does not prevent phishing, credential capture, or drive-by abuse on the profile domain.\n\nRemediation: Tighten CSP (drop unsafe-inline, restrict script-src to 'self' or nonces/hashes), and keep serving static sites only on isolated hosts with COOP/CORP hardened defaults.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/plugs/static_site_plug.ex",
      "line_end": 45,
      "line_start": 29,
      "severity": "medium",
      "title": "Static site CSP allows unsafe-inline and any HTTPS scripts"
    },
    {
      "description": "verify_webhook_branch only enforces deployment.branch for refs matching refs/heads/*; any other payload (tags, missing ref, alternate ref shapes) returns :ok. A valid push event with a non-heads ref and a valid webhook signature can still enqueue StaticSites.enqueue_github_deploy, bypassing the intended branch allowlist as a redeploy trigger.\n\nRemediation: Reject refs that are not refs/heads/<allowed-branch>, and only enqueue deploy for exact branch matches.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/controllers/api/static_site_controller.ex",
      "line_end": 194,
      "line_start": 186,
      "severity": "medium",
      "title": "GitHub deploy webhook branch check skipped for non-heads refs"
    },
    {
      "description": "valid_server_api_key?/2 hashes only keys stored with the sha256: prefix; otherwise it compares the Authorization bearer token to the database value with secure_compare. Any legacy/unmigrated server rows keep the API key recoverable from the DB and usable for peer sync, stats, and key registration endpoints under /api/vpn/*.\n\nRemediation: Rehash all stored server API keys to sha256: form, refuse plaintext matches, and force rotation for unmigrated keys.",
      "disposition": "new",
      "file": "apps/elektrine_vpn/lib/elektrine/vpn.ex",
      "line_end": 450,
      "line_start": 445,
      "severity": "medium",
      "title": "Legacy VPN server API keys compared as plaintext"
    },
    {
      "description": "allow_insecure_receiver_webhook? falls back to true when the :allow_insecure_receiver_webhook setting is absent. If runtime/email config fails to load that key, Elektrine.Email.Receiver may accept inbound webhooks without a secret, enabling spoofed mail injection into user mailboxes.\n\nRemediation: Default the helper to false and require an explicit allow_insecure flag only outside production.",
      "disposition": "new",
      "file": "apps/elektrine_email/lib/elektrine/email_config.ex",
      "line_end": 40,
      "line_start": 39,
      "severity": "medium",
      "title": "Insecure email receiver webhook default is true"
    },
    {
      "description": "trusted_app_for_user?/2 auto-approves authorization when app.trusted is true and user_id is nil, issuing codes without the consent screen for any signed-in user. A mis-set trusted row (or first-party app without owner binding) becomes a silent OAuth consent bypass for all accounts.\n\nRemediation: Require a non-nil owner for trusted apps, or limit auto-approve to apps owned by the authorizing user or an explicit first-party allowlist.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/controllers/oidc_controller.ex",
      "line_end": 585,
      "line_start": 583,
      "severity": "medium",
      "title": "Trusted OIDC apps with nil owner skip user consent for everyone"
    },
    {
      "description": "request_headers/2 copies nearly all inbound headers (including Cookie and Authorization) to the configured origin after only hop-by-hop/host filtering. A proxied origin controlled by a DNS zone owner receives visitor credentials/session material for that hostname and any other forwarded auth headers.\n\nRemediation: Strip Cookie, Authorization, and other sensitive headers by default; allow only an explicit header allowlist for edge proxy forwarding.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/plugs/dns_edge_proxy.ex",
      "line_end": 98,
      "line_start": 85,
      "severity": "medium",
      "title": "DNS edge proxy forwards Cookie and Authorization to origin"
    },
    {
      "description": "decrypt_legacy_private_key/1 derives AES-256-GCM key material by taking binary_part(secret_key_base, 0, 32) with empty AAD. That couples long-lived VPN private keys to the web session signing secret and skips the stronger PBKDF2 path used by vpn:v1.\n\nRemediation: Re-encrypt all legacy keys under vpn:v1 with dedicated KDF material and remove the secret_key_base slice path after migration.",
      "disposition": "new",
      "file": "apps/elektrine_vpn/lib/elektrine/vpn.ex",
      "line_end": 1182,
      "line_start": 1175,
      "severity": "medium",
      "title": "Legacy VPN key decryption uses raw secret_key_base slice"
    },
    {
      "description": "proxy_origin_port accepts any integer in 1..65535 and stores it for edge proxying. Combined with SafeFetch, private IPs are blocked, but users can still aim the platform reverse-proxy at uncommon public ports and widen the attack surface of the shared edge.\n\nRemediation: Restrict origin_port to 80/443 (or a small operator allowlist) and reject other ports at changeset validation.",
      "disposition": "new",
      "file": "apps/elektrine_dns/lib/elektrine/dns/record.ex",
      "line_end": 229,
      "line_start": 134,
      "severity": "low",
      "title": "Proxied DNS origins accept any TCP port 1-65535"
    },
    {
      "description": "validate_uri/1 for http/https checks host and dangerous ports but not userinfo, unlike validate_websocket/2 and SafeExternalURL. Callers that only use URLValidator may accept credential-bearing URLs (http://user:pass@host) that later leak secrets into logs, metrics, or outbound requests.\n\nRemediation: Reject non-nil userinfo for all schemes in URLValidator.validate/1 consistently.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/security/url_validator.ex",
      "line_end": 96,
      "line_start": 79,
      "severity": "low",
      "title": "HTTP(S) URL validation does not reject userinfo"
    },
    {
      "description": "avatar_url is only length-validated (max 500). Execute-path overrides similarly accept any string up to length limits. Unsafe schemes or tracker URLs can be stored and later rendered by API clients that do not use safe_chat_image_url.\n\nRemediation: Require http(s) via SafeExternalURL.normalize_href/1 (or equivalent) on create/update and webhook execute overrides.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/messaging/chat_webhook.ex",
      "line_end": 109,
      "line_start": 104,
      "severity": "low",
      "title": "Chat webhook avatar_url lacks scheme/host validation"
    },
    {
      "description": "validate_override/2 only checks non-empty length for username/avatar_url on unauthenticated webhook execute. Callers can inject javascript:/data: or other malicious URLs into message metadata for clients that trust webhook_sender.avatar_url.\n\nRemediation: Parse and allow only http/https avatar URLs (and optionally block private hosts) before persisting overrides.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex",
      "line_end": 254,
      "line_start": 247,
      "severity": "low",
      "title": "Webhook execute overrides do not validate avatar URL safety"
    },
    {
      "description": "registration_changeset and admin_registration_changeset only require length 8..72 with no complexity, breached-password, or entropy checks. Weak passwords increase credential-stuffing and online guessing risk for accounts that lack 2FA/passkeys.\n\nRemediation: Raise minimum length (e.g. 12+), add zxcvbn/HIBP checks, and require stronger passwords for admins.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/accounts/user.ex",
      "line_end": 227,
      "line_start": 192,
      "severity": "low",
      "title": "Password policy allows short 8-character passwords"
    },
    {
      "description": "hash_secret/1 stores client_secret as hex(SHA-256(secret)) without salt/pepper or a password-hashing KDF. High-entropy random secrets reduce practical offline cracking, but DB leaks still allow fast offline verification and are weaker than Argon2/bcrypt for any lower-entropy secrets.\n\nRemediation: Store secrets with a dedicated peppered HMAC or Argon2id and rotate existing clients.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/oauth/app.ex",
      "line_end": 291,
      "line_start": 289,
      "severity": "low",
      "title": "OAuth client secrets stored as unsalted SHA-256"
    },
    {
      "description": "ApiToken.hash_token/1 uses bare SHA-256 of the full token. Tokens appear high-entropy, but a stolen token_hash table enables efficient offline validation without per-token salts or a slow KDF.\n\nRemediation: Prefer HMAC-SHA256 with a server pepper or Argon2id for PAT storage and migrate existing hashes.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/developer/api_token.ex",
      "line_end": 298,
      "line_start": 297,
      "severity": "low",
      "title": "Personal access tokens hashed with unsalted SHA-256"
    },
    {
      "description": "require_admin_host/2 allows /pripyat admin routes on the public app host whenever NetBird is disabled. Self-hosted deployments without NetBird expose the admin surface on the main hostname, increasing brute-force and XSS/CSRF impact against admin sessions.\n\nRemediation: Always require a dedicated admin host (or VPN) in production, independent of NetBird.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/user_auth.ex",
      "line_end": 614,
      "line_start": 605,
      "severity": "low",
      "title": "Admin host restriction only applies when NetBird is enabled"
    },
    {
      "description": "Federated HTML links may include target and rel attributes freely. target=_blank without rel=noopener noreferrer enables reverse-tabnabbing/phishing against users opening remote posts.\n\nRemediation: Force rel=\"noopener noreferrer\" (and strip attacker-controlled rel) for any target=_blank links during scrubbing.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/scrubbers/remote_content.ex",
      "line_end": 43,
      "line_start": 42,
      "severity": "low",
      "title": "Remote content allows target without forcing noopener"
    },
    {
      "description": "Sanitized embeds are rebuilt with sandbox allow-scripts allow-same-origin allow-presentation. Combining those flags is a known sandbox weakening pattern if a trusted embed host is ever compromised or mis-matched.\n\nRemediation: Prefer allow-scripts without allow-same-origin where possible, or use provider-specific player params without same-origin sandbox privileges.",
      "disposition": "new",
      "file": "apps/elektrine_social/lib/elektrine/social/oembed.ex",
      "line_end": 230,
      "line_start": 226,
      "severity": "low",
      "title": "oEmbed iframes use allow-scripts with allow-same-origin"
    },
    {
      "description": "allowed domains are cached for 5 minutes and denials for 15. After a custom domain is unverified/removed, Caddy can still receive \"allowed\" and issue or serve certs during the TTL window.\n\nRemediation: Invalidate cache entries on domain verification revoke, or use a very short allow TTL with explicit purge hooks.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/controllers/caddy_tls_controller.ex",
      "line_end": 67,
      "line_start": 11,
      "severity": "low",
      "title": "On-demand TLS allow decisions cached after domain changes"
    },
    {
      "description": "validate_webhook/1 compares the shared secret against body fields webhook_secret/signature/token/auth rather than a signature header over the raw body. Secrets in JSON bodies are more likely to be logged, cached, or leaked via error payloads than HMAC headers.\n\nRemediation: Prefer HMAC of the raw body in a dedicated header and stop accepting the raw secret in the JSON body.",
      "disposition": "new",
      "file": "apps/elektrine_email/lib/elektrine/email/receiver.ex",
      "line_end": 99,
      "line_start": 86,
      "severity": "low",
      "title": "Email receiver webhook secret accepted from body parameters"
    },
    {
      "description": "Outside config_env()==:prod, encryption_master_secret/key/search salts fall back to SHA-256 digests of fixed strings. Any deployment accidentally run as non-prod without real secrets uses known keys, defeating at-rest encryption.\n\nRemediation: Refuse to boot without unique encryption secrets except for explicit local mix env, and never ship the fixed fallbacks outside developer machines.",
      "disposition": "new",
      "file": "config/runtime.exs",
      "line_end": 549,
      "line_start": 510,
      "severity": "info",
      "title": "Non-prod encryption secrets are deterministic public fallbacks"
    },
    {
      "description": "Authenticated only by CADDY_EDGE_API_KEY, add_txt can create TXT records for any zone matching the challenge domain hierarchy. Key leakage yields DNS record write capability across hosted zones (limited to _acme-challenge names).\n\nRemediation: Scope the edge key per zone/account, bind callers by network policy, and audit/rate-limit TXT mutations.",
      "disposition": "new",
      "file": "apps/elektrine_web/lib/elektrine_web/controllers/internal_acme_dns_controller.ex",
      "line_end": 50,
      "line_start": 6,
      "severity": "info",
      "title": "Shared edge API key can write ACME TXT on any matching zone"
    },
    {
      "description": "dangerous_port?/1 blocks a short list (22, 25, 3306, 5432, 6379, etc.) but allows many sensitive service ports. Private IP blocking mitigates internal SSRF, yet open-proxy/media-fetch paths can still probe uncommon public ports.\n\nRemediation: Prefer an allowlist of 80/443 (plus explicitly supported ports) instead of a partial denylist.",
      "disposition": "new",
      "file": "apps/elektrine/lib/elektrine/security/url_validator.ex",
      "line_end": 496,
      "line_start": 470,
      "severity": "info",
      "title": "SSRF dangerous-port denylist is incomplete"
    }
  ],
  "tarakan_scan_format": 1
}

Independent verification

0 confirmed · 0 disputed · status open

No checks have been submitted at this commit.

Sign in to confirm or dispute this finding.

Discussion

0 comments

Full security record for atomine-elektrine/elektrine