Skip to content

Security Sweep 2026-06-27: 1 new finding #116

Description

@edictum-reviewer

Security Sweep Report — 2026-06-27

Full sweep mode: false (new findings and regressions only)


New Findings (not in baseline)

[LOW] NEW-A — Discord interaction endpoint missing timestamp freshness check

File: src/edictum_server/routes/discord.py:50-101

Attack:
The Slack webhook handler (routes/slack.py:65) validates abs(time.time() - ts_int) > 300, rejecting any interaction with a timestamp older than 5 minutes. This prevents replay attacks where an attacker captures a valid signed payload and submits it again later.

The Discord handler fetches x-signature-timestamp and uses it in signature verification (timestamp.encode() + body), but never checks whether the timestamp is recent. An attacker who intercepts or captures a valid Discord interaction HTTP request (via network sniffing, a compromised proxy, or browser-level tooling) can replay the signed payload at any future time. The Ed25519 signature remains mathematically valid regardless of age.

Practical exploit scenario:

  1. Attacker observes a legitimate Discord "deny" button click for approval ID abc-123.
  2. The approval is decided; the original interaction succeeds.
  3. A new approval with the same tool comes in (different UUID), but approval abc-123 is recreated by the agent (if the agent retries). Since submit_decision is atomic on UUID, this replay scenario requires the exact UUID to still be pending — unlikely but possible in edge cases.
  4. More realistically, an insider with access to network traffic could replay a captured interaction for an approval that was pending but hadn't been submitted yet.

Discord's own security documentation explicitly requires timestamp verification with a 5-minute window.

Fix:

# After extracting timestamp:
try:
    ts_int = int(timestamp)
except ValueError:
    return Response(status_code=401)
if abs(time.time() - ts_int) > 300:
    return Response(status_code=401)

Add these lines to discord_interaction() before the signature search loop (mirroring the Slack handler at routes/slack.py:61-66).

Effort: ~10 minutes


Regressions (fixed findings that reappeared)

None found. All 9 previously-fixed findings are verified still fixed.


Baseline Findings Still Open

ID Severity Status File Description
H1-https-downgrade High fix-planned config.py HTTPS to HTTP downgrade redirect + misconfigured trusted proxies
M1-null-byte Medium fix-planned routes/auth.py Null byte in login email field still causes 500 — reject_null_bytes validator was added to password only
M4-ssrf Medium fix-planned services/notification_service.py AI provider base_url validated at save time only; DNS rebinding gap at request time for OpenAI-compatible provider
M6-rate-limiting Medium fix-planned rate_limit.py Telegram, Slack, Discord webhook endpoints have no rate limiting; setup endpoint unrated
M10-encryption Medium fix-planned services/notification_service.py Plaintext config fallback in get_channel_config() still returns channel.config or {} when secret is None
M13-pagination Medium fix-planned routes/bundles.py list_approvals has unbounded limit up to 1000; some other list endpoints unbounded
L6-input-validation Low fix-planned routes/auth.py Login email missing null byte rejection; HTML injection possible in some text fields via notification channels
L7-dependency-pinning Low fix-planned pyproject.toml No uv.lock or requirements.txt pinned lockfile for Python deps
NEW-1 Medium fix-planned ai/ollama.py Ollama provider bypasses SafeTransport — raw SDK client, DNS rebinding gap
NEW-2 Medium fix-planned schemas/events.py EventPayload, ManifestContract, AgentManifest string fields missing max_length
NEW-3 Medium fix-planned schemas/contracts.py ContractCreateRequest.tags and ContractUpdateRequest.tags missing max_length on list and elements
NEW-4 Low fix-planned schemas/notifications.py RoutingFilters lists (environments, agent_patterns, contract_names) missing max_length
NEW-5 Low fix-planned routes/setup.py SetupRequest.tenant_name missing max_length
NEW-6 Low fix-planned routes/stream.py SSE query parameters (env, bundle_name, policy_version, tags) missing max_length
NEW-7 Low fix-planned routes/auth.py Login rate limit keyed on proxy IP when EDICTUM_TRUSTED_PROXIES not configured
NEW-8 Low fix-planned security/safe_transport.py socket.getaddrinfo() in SafeTransport.handle_async_request() is synchronous — blocks event loop

What's Working Well

Authentication & Sessions (S1, S2, S7, S8)

  • Sessions are HMAC-SHA256 signed with EDICTUM_SECRET_KEY; tampered sessions are destroyed on detection (C2 fixed).
  • Login always runs bcrypt via _DUMMY_HASH — no timing oracle for account enumeration (M2 fixed).
  • Bootstrap lock uses Postgres advisory lock (pg_advisory_xact_lock(42)) shared with the env-var bootstrap path — concurrent /api/v1/setup races are blocked at the DB level.
  • API keys use bcrypt + SHA256 pre-hash; prefix-based lookup prevents linear scan; verify_api_key is constant-time via bcrypt comparison.
  • Secret key minimum 32 characters enforced at startup via validate_required().
  • Sessions carry absolute 7-day lifetime enforced even after Redis TTL sliding.
  • CSRF middleware with X-Requested-With pattern; API key requests correctly exempted.

Tenant Isolation (S3)

  • Every select(), update(), delete() in all service functions is scoped by tenant_id (reviewed all files in services/).
  • Webhook handlers (Telegram, Slack, Discord) perform explicit tenant cross-check: Redis-resolved tenant_id is compared against the channel's own tenant_id before submitting any decision.
  • Push manager (push_to_env, push_to_agent) filters by tenant_id before delivering events.
  • Dashboard SSE stream is subscribed per tenant_id; agent SSE streams filtered by tenant_id in push_to_env.

Approval Integrity (S4)

  • submit_decision() uses single atomic UPDATE ... WHERE status='pending' AND NOT expired RETURNING — prevents both TOCTOU races and expiry races (C1 fixed).
  • decided_by and agent_id come from auth context, never from request body.
  • Approval creation rate-limited per tenant + agent (10/min).

Security Headers (H2)

  • All responses carry: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy.

Body Size Limits (M3)

  • BodySizeLimitMiddleware enforces route-specific limits (4KB auth, 5MB uploads, 1MB default) with two-phase enforcement (Content-Length header + streaming byte counter).

SSRF (partial)

  • Notification channel test uses SafeTransport — blocks private/cloud metadata networks at request time.
  • Channel config URLs validated with validate_url() at creation and update.
  • AI provider base_url validated at save time in ai_service.py.
  • Session store keys scoped by tenant_id; key pattern validated against [a-zA-Z0-9_\-\.:/]+.

Infrastructure

  • Docker: internal network isolates Postgres and Redis from public; server only on default network.
  • Postgres uses non-superuser edictum account (via docker/postgres-init.sh).
  • Redis requires password (requirepass in compose).
  • uvicorn hardened: --limit-concurrency 100, --limit-max-requests 10000, --timeout-keep-alive 5.
  • Non-root container user (app:app, uid 1000).
  • Migration lock via pg_advisory_lock(43) prevents concurrent Alembic runs.
  • Redis keys all have TTL: sessions (24h configurable), rate limit sets (window + 60), session state (7 days).

Signing & Cryptography

  • NaCl SecretBox (XSalsa20-Poly1305) for notification config and AI key encryption.
  • Ed25519 signing keys for bundle deployment.
  • EDICTUM_SIGNING_KEY_SECRET must be exactly 32 bytes (64 hex chars) — validated in get_signing_secret().
  • All webhook secret comparisons use hmac.compare_digest().

Health Endpoint (M5)

  • Public /api/v1/health returns only {status, bootstrap_complete}.
  • Full details at /api/v1/health/details behind dashboard auth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity-related issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions