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:
- Attacker observes a legitimate Discord "deny" button click for approval ID
abc-123.
- The approval is decided; the original interaction succeeds.
- 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.
- 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.
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-101Attack:
The Slack webhook handler (
routes/slack.py:65) validatesabs(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-timestampand 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:
abc-123.abc-123is recreated by the agent (if the agent retries). Sincesubmit_decisionis atomic on UUID, this replay scenario requires the exact UUID to still be pending — unlikely but possible in edge cases.Discord's own security documentation explicitly requires timestamp verification with a 5-minute window.
Fix:
Add these lines to
discord_interaction()before the signature search loop (mirroring the Slack handler atroutes/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
config.pyroutes/auth.pyreject_null_bytesvalidator was added topasswordonlyservices/notification_service.pybase_urlvalidated at save time only; DNS rebinding gap at request time for OpenAI-compatible providerrate_limit.pyservices/notification_service.pyget_channel_config()still returnschannel.config or {}when secret is Noneroutes/bundles.pylist_approvalshas unboundedlimitup to 1000; some other list endpoints unboundedroutes/auth.pypyproject.tomluv.lockorrequirements.txtpinned lockfile for Python depsai/ollama.pyschemas/events.pyEventPayload,ManifestContract,AgentManifeststring fields missingmax_lengthschemas/contracts.pyContractCreateRequest.tagsandContractUpdateRequest.tagsmissingmax_lengthon list and elementsschemas/notifications.pyRoutingFilterslists (environments,agent_patterns,contract_names) missingmax_lengthroutes/setup.pySetupRequest.tenant_namemissingmax_lengthroutes/stream.pyenv,bundle_name,policy_version,tags) missingmax_lengthroutes/auth.pyEDICTUM_TRUSTED_PROXIESnot configuredsecurity/safe_transport.pysocket.getaddrinfo()inSafeTransport.handle_async_request()is synchronous — blocks event loopWhat's Working Well
Authentication & Sessions (S1, S2, S7, S8)
EDICTUM_SECRET_KEY; tampered sessions are destroyed on detection (C2 fixed)._DUMMY_HASH— no timing oracle for account enumeration (M2 fixed).pg_advisory_xact_lock(42)) shared with the env-var bootstrap path — concurrent/api/v1/setupraces are blocked at the DB level.verify_api_keyis constant-time via bcrypt comparison.validate_required().Tenant Isolation (S3)
select(),update(),delete()in all service functions is scoped bytenant_id(reviewed all files inservices/).tenant_idis compared against the channel's owntenant_idbefore submitting any decision.push_to_env,push_to_agent) filters bytenant_idbefore delivering events.tenant_id; agent SSE streams filtered bytenant_idinpush_to_env.Approval Integrity (S4)
submit_decision()uses single atomicUPDATE ... WHERE status='pending' AND NOT expired RETURNING— prevents both TOCTOU races and expiry races (C1 fixed).decided_byandagent_idcome from auth context, never from request body.Security Headers (H2)
Content-Security-Policy,Strict-Transport-Security,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy.Body Size Limits (M3)
BodySizeLimitMiddlewareenforces route-specific limits (4KB auth, 5MB uploads, 1MB default) with two-phase enforcement (Content-Length header + streaming byte counter).SSRF (partial)
SafeTransport— blocks private/cloud metadata networks at request time.validate_url()at creation and update.base_urlvalidated at save time inai_service.py.tenant_id; key pattern validated against[a-zA-Z0-9_\-\.:/]+.Infrastructure
edictumaccount (viadocker/postgres-init.sh).requirepassin compose).--limit-concurrency 100,--limit-max-requests 10000,--timeout-keep-alive 5.app:app, uid 1000).pg_advisory_lock(43)prevents concurrent Alembic runs.window + 60), session state (7 days).Signing & Cryptography
SecretBox(XSalsa20-Poly1305) for notification config and AI key encryption.EDICTUM_SIGNING_KEY_SECRETmust be exactly 32 bytes (64 hex chars) — validated inget_signing_secret().hmac.compare_digest().Health Endpoint (M5)
/api/v1/healthreturns only{status, bootstrap_complete}./api/v1/health/detailsbehind dashboard auth.