Skip to content

Security Model

This page describes FreeSDN’s full security architecture: how identities are established, how access is controlled, how tenant data is isolated, how writes are guarded, and how the supply chain is hardened. The design uses defence-in-depth with fail-closed defaults at every trust boundary.


Passwords are hashed with Argon2id (64 MB memory cost, t=3, p=4). No plaintext or reversible password storage exists.

Users can enroll a TOTP authenticator (RFC 6238, via pyotp). Enrollment generates one-time backup codes. MFA status is tracked with token_version, which increments whenever MFA is toggled - immediately invalidating all active sessions for that account.

  • OIDC - operational; configured per-org with client ID, client secret, and OpenID Connect discovery URL (issuer endpoint); the JWKS URI is resolved automatically from the discovery document.
  • LDAP - operational; bind DN, search base, and attribute mapping configurable per-org.
  • SAML - the endpoint exists but returns 501 Not Implemented. SAML is not available in this release.

Access and refresh tokens use HS256. Every token carries:

  • iss: freesdn / aud: freesdn-api - mismatches are rejected outright.
  • jti - a per-token identifier added to a revocation blacklist on logout or forced-logout-all.
  • tv (token_version) - stamped at issue time as the claim key tv and checked on every authenticated request. Bumped on password change, logout-all, and MFA toggle. Any token issued before the bump is immediately invalid - no waiting for expiry.

API keys carry an explicit permission scope declared at creation time. A scoped key acts as a hard ceiling: even if the key belongs to a super_admin, the super_admin wildcard bypass is suppressed and the key can only exercise the declared permissions. This is enforced in CurrentUser.has_permission (dependencies.py:456), where the super_admin and wildcard bypass are skipped when self._scoped is true.


super_admin (100) > admin (80) > org_admin (60) > site_admin (40)
> operator (20) > viewer (10) > guest (0)

Role assignment uses a strict-lower-than rule: you can only assign a role whose numeric level is strictly below your own. An org_admin cannot create another org_admin. This is enforced in validate_role_assignment and raises HTTP 403 on violation.

is_superuser is a computed property on CurrentUser that returns True when the user holds the super_admin role. No separate flag exists - the role alone determines cross-org access.

See Roles and Permissions for the full permission matrix.

Within an organization, you can scope a user’s access to specific sites:

  • A user with one or more UserSiteAccess grants is site-limited: they can only reach resources belonging to their granted sites.
  • A user with zero grants operates across all sites in their org (backwards-compatible default).

CurrentUser.is_site_limited and can_access_site are checked in the dependency layer (dependencies.py:514-540). The assert_can_access_site / site_scope_filter primitives in app/core/site_access.py enforce this on list and single-resource paths.


Reads and writes are org-scoped at the application layer throughout the service layer. The pattern:

  1. Queries join on organization_id derived from the authenticated user’s token.
  2. The site_access.py primitive enforces site-level filtering on top of org scoping.
  3. If the org scope cannot be determined, the request is rejected (fail-closed).

The WebSocket event bus enforces the same boundary: connections and events are dropped - not silently misrouted - when org_id is missing or mismatched.


All adapter writes (network config pushes, device mutations, firewall rule changes, etc.) pass through AdapterStagingService, which enforces a two-factor gate before any change reaches a live device:

  1. ADAPTER_READ_ONLY=false must be set in the environment. In its default state (true) all write paths are blocked.
  2. force=true must be present in the request. Without it, the write is rejected at the service layer.

Both conditions must be satisfied simultaneously - one alone is not enough.

UI-authored writes are staged to the database first. No live device contact is made until an operator explicitly applies the staged change. At apply time:

  • Org ownership is re-checked against the staged change.
  • The required permission is derived from change.feature (not the URL), preventing permission-map substitution attacks.
  • Catastrophic operations (node shutdown, VM destroy, firmware flash, factory reset) additionally require the site_admin minimum role.
  • An AuditLogRecord is written on every apply and discard.

A concurrent apply race is blocked by an atomic SELECT … FOR UPDATE that advances state to applying and returns HTTP 409 if the change is already in that state.


All adapter responses pass through app.core.redaction.redact_secrets before reaching any API consumer. The strip-list covers approximately 90 sensitive key names, matched after a three-step normalisation: (1) camelCase/PascalCase boundaries are split with underscores (so preSharedKeypre_shared_key), (2) the result is lower-cased, and (3) hyphens are converted to underscores - so RouterOS-style pre-shared-key, JSON-style pre_shared_key, and camelCase preSharedKey all collapse to the same strip-list entry.

Key categories covered:

  • Generic auth: password, api_key, token, client_secret, credential, cookie, session_token
  • VPN / cryptographic material: private_key, psk, pre_shared_key, tls_key, tls_auth, tls_crypt, shared_secret, ipsec_secret, wireguard_private_key
  • RADIUS / SNMP: radius_secret, shared_key, snmp_community, auth_password, encryption_password
  • OTP / MFA: mfa_secret, mfa_backup_codes, otp_secret
  • Vendor-specific: UniFi x_passphrase / x_password / x_iapp_key; Proxmox vncticket, csrf_prevention_token, cipassword; RouterOS auth_key, key_passphrase

The function is recursion-bounded to prevent stack exhaustion from pathologically nested vendor responses.

See Secrets and Credentials for credential encryption and rotation.


All outbound HTTP requests (Controller probes, webhook delivery, SSO token exchange, plugin HTTP calls, camera AI-vision) are routed through safe_http_request in app/core/security_utils.py. It:

  • Resolves the hostname to IP once, validates the resolved IP, then pins the connection to that IP for the lifetime of the request.
  • Unconditionally rejects loopback, link-local, multicast, reserved/unspecified addresses, and explicit cloud instance-metadata IPs - these are blocked regardless of configuration. RFC 1918 private ranges and CGNAT (100.64.0.0/10) are rejected for untrusted (operator-supplied) URLs but are permitted when the hostname appears in the allow_hosts allowlist - a deploy-owner-configured trust list used for webhook and Fabric destinations on private LANs or Tailscale networks.
  • Handles IPv4-mapped IPv6 addresses (::ffff:192.168.x.x) - they cannot bypass the RFC 1918 guard.
  • Follows zero redirects - a redirect chain cannot reach an internal address after the initial validation.

DNS rebinding is prevented because the resolved IP is pinned; a DNS record change mid-connection has no effect.


The desktop/daemon agent (freesdn-agent) verifies every update package against an ECDSA-P256 signature before applying it. Missing or invalid signatures cause the updater to fail closed - no update is applied. The agent pins the release signing key fingerprint on first use (trust-on-first-use) and refuses a later key-swap; the release_public_key_sha256 baked in at install time takes precedence.

/api/v1/marketplace/plugins/sync requires an Ed25519 detached signature over the canonical catalog JSON, verified against a pinned publisher key (MARKETPLACE_PUBLISHER_PUBLIC_KEY). An unsigned catalog is refused by default. The signing tool is at backend/scripts/sign_marketplace_catalog.py.

Plugin packages can declare dependency hashes. Hash-pinning is disabled by default and must be opted into per-plugin. Runtime dependency installation is also off by default.

All third-party GitHub Actions in the two privileged root workflows are pinned to commit SHAs. Dependabot is configured on the github-actions ecosystem to maintain those pins automatically.


Plugins execute with the intersection of the plugin’s declared permissions and the calling user’s permissions - no confused-deputy escalation. Plugin-initiated device writes are categorically refused by the executor. Plugin installation is restricted to super_admin.


Every response includes:

HeaderValue
Content-Security-PolicyRestrictive, self-origin
Strict-Transport-Security2-year max-age (when HTTPS is active)
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
Permissions-PolicyDisables camera, microphone, geolocation, payment

This release includes automated tests and internal review, but no third-party security audit or certification is claimed. Security regression tests live under backend/tests/security/test_pentest_*.py.


Next steps: Roles and Permissions - Production Hardening Checklist - Reporting Vulnerabilities

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.