Skip to content

Security: muffti123/Deraedge

Security

SECURITY.md

Security

This document describes the real, as-implemented security posture of the DeraEdge platform — not an aspirational one. Where something is not yet done, it's stated plainly.

Password storage

Passwords are hashed with bcrypt (golang.org/x/crypto/bcrypt, bcrypt.DefaultCost) in backend/internal/services/password.go. Plaintext passwords are never logged or stored. There is no password-strength meter enforced server-side beyond whatever internal/validation requires at registration/reset time — review internal/validation directly for the current minimum before relying on it for a compliance claim.

Session model

Sessions are opaque server-side tokens, not JWTs — see ARCHITECTURE.md for the reasoning. Concretely:

  • On login/register, a random token is generated, its SHA-256 hash is stored in the sessions table (token_hash, unique), and the raw token is set as the de_session cookie (HttpOnly, SameSite=Lax, Path=/; Secure + Domain=SESSION_COOKIE_DOMAIN only in production).
  • Every request presenting the cookie is resolved by hashing the incoming token and looking up the active (non-expired, non-revoked) session row — internal/middleware.Auth. The database is the sole source of truth; nothing is trusted from the token's shape alone.
  • Sessions expire after 30 days (SessionTTL) from creation.
  • Logout revokes exactly that one session (sessions.revoked_at).
  • Password reset revokes all existing sessions for the user, not just the current one — a credential-stuffing or session-theft scenario is fully contained the moment the legitimate user resets their password.
  • Because revocation is a single UPDATE/lookup against a real table, there is no propagation delay and no denylist to keep in sync — the tradeoff (a DB round-trip per authenticated request) is accepted deliberately.

CSRF protection

Double-submit-style, session-bound, but without a second cookie:

  • csrfToken is returned in the JSON body of login, register, and GET /auth/me — never in a cookie, so it isn't automatically attached by the browser and must be deliberately read and forwarded by frontend JS.
  • The expected value is derived deterministically per-session as sha256(sessionTokenHash + SERVER_SECRET) (internal/services.DeriveCSRFToken) — nothing extra is stored; it's recomputed on each check.
  • Every mutating request (POST/PUT/PATCH/DELETE) made with a session cookie must include header X-CSRF-Token matching that derived value, compared with crypto/subtle.ConstantTimeCompare to avoid timing side-channels. Mismatch or missing header → 403 csrf_invalid.
  • Requests with no session cookie (login, register, the public contact form) are exempt — there is no session-bound secret to check yet. Those endpoints rely on CORS + per-IP rate limiting instead.
  • SERVER_SECRET is mandatory in production (config.Load fails startup without it) and only auto-defaults to an insecure placeholder outside production.

CORS

internal/middleware.CORS allows only origins present verbatim in CORS_ALLOWED_ORIGINS (comma-separated exact match, no wildcards, no subdomain pattern matching) and sets Access-Control-Allow-Credentials: true — required because auth is cookie-based. Every deployment environment must explicitly enumerate every origin (web, admin, and any preview/staging URL) that needs credentialed access; nothing is allowed by default.

Rate limiting

An in-process per-IP token bucket (golang.org/x/time/rate) guards auth endpoints (/auth/register, /auth/login, /auth/forgot-password) and the public contact form (/contact/partner-inquiry). Client IP is read from X-Forwarded-For if present, else the connection's remote address.

Known limitation, stated plainly: this limiter's state is per-instance only, held in that process's memory (internal/middleware/ratelimit.go). It is not shared across replicas. If the backend is horizontally scaled to N instances, the effective global rate limit becomes configured_limit × N, because each instance tracks visitors independently with no shared store. A correct fix would be a shared store (Redis) backing the limiter; this has not been built. Until then, treat the configured rate limit as a per-instance ceiling, not a global guarantee, when sizing for abuse resistance.

Stripe webhook verification

POST /stripe/webhook reads the raw body (capped at 64KB via http.MaxBytesReader) and verifies the Stripe-Signature header against STRIPE_WEBHOOK_SECRET before processing anything (internal/stripe, internal/handlers/stripe_webhook.go). An invalid or missing signature is rejected 400 before any DB write happens. Processing is idempotent: the stripe_events table has a unique constraint on stripe_event_id; redelivering the same event is a no-op rather than double-crediting an enrollment or double-sending emails — this was verified with an actual duplicate-delivery integration test, not just code inspection. Live signature verification requires a real Stripe-issued STRIPE_WEBHOOK_SECRET; the verification code path itself is exercised in tests using stripe-go's synthetic signed-payload helpers with a test secret.

RBAC: backend-authoritative, always

Three roles: public → student (automatic, only via a confirmed Stripe webhook on paid enrollment — never client-initiated) → admin (manual only; there is no public admin registration endpoint or path). The entire /admin/* route group is gated by middleware.RequireRole(models.RoleAdmin), which re-reads the authenticated user's role from the users table on every request via the session lookup — a client can never supply or influence its own role. There is no role claim embedded in the session token to trust; the token is opaque and carries no data at all.

Entitlement checks follow the same principle and are re-verified per request, never cached or inferred:

  • POST /enrollments/checkout derives the charge amount from courses.price_cents server-side; the client only supplies a course slug.
  • POST /progress/lessons/:lessonId/complete returns 403 unless the caller currently has an active enrollment for that lesson's course.
  • GET /certificates/:courseId/download re-checks 100% lesson completion against lesson_progress on every download call, not just at certificate-issuance time.
  • Journal entry mutations (PATCH/DELETE /journal/:id) return 404 (never 403) when the entry isn't owned by the caller, to avoid leaking whether the entry exists at all.

Audit logging

Every mutating admin action writes a row to audit_logs (actor_user_id, action, target_type, target_id, metadata as JSONB) via internal/repository.AuditRepo.Log — role changes, enrollment grants/revokes, research article create/update/publish/unpublish/delete, course updates. GET /admin/audit exposes this log (paginated) to admins in the console. There is currently no tamper-evidence mechanism (e.g. hash chaining) on the audit log itself — it's a plain insert-only table protected by normal DB access controls, not a cryptographically verifiable ledger.

What is NOT yet done — honest gaps

  • No WAF or dedicated DDoS mitigation beyond whatever Cloudflare's platform defaults provide at the DNS/CDN layer for web/admin. There is no custom firewall ruleset, bot management, or challenge configured.
  • Rate limiting does not survive horizontal scaling correctly (see above) — no shared store.
  • No 2FA/MFA for any role, including admin.
  • No IP allowlisting for the admin console — admin is reachable from anywhere on the public internet provided valid credentials; its only current protection beyond auth is that it's not linked from web's public navigation (obscurity, not access control).
  • No automated dependency/vulnerability scanning wired into CI as of this writing (verify against whatever CI config exists at the time you read this).
  • No secrets manager integration — secrets are environment variables injected by the hosting platform (Render, Vercel), not pulled from a dedicated vault at runtime.
  • Audit log is not tamper-evident — see above.
  • Email deliverability/anti-spoofing (SPF/DKIM/DMARC for the sending domain) is a DNS-level concern tracked in DEPLOYMENT.md, not in this codebase.

None of these are silent — they're listed here so a reader doesn't assume more coverage than exists.

There aren't any published security advisories