Repository navigation
Dev Security Assurance
This page argues why MowDeck's security requirements are met. It covers the threat model, the trust boundaries, how the secure design principles are applied, how common implementation weaknesses are countered, and which risks are knowingly accepted. It pulls the security-relevant decisions of the ADRs and the operations guides into one argument and links to them for detail. How to report a vulnerability is in SECURITY.md.
Last full review: 2026-11-04 (maintainer, see Review record).
A MowDeck installation deployed as documented keeps each account's mowers, maps, schedules, camera images and linked vendor credentials confidential and intact against other accounts, against unauthenticated network clients and against API tokens acting outside the grants their creator gave them. Only someone with the matching right can move a mower, and the emergency stop is never blocked by MowDeck. When a credential, a token or an input is invalid, the request is rejected.
What it does not claim: protection against an attacker who already controls the host, a container, the database or the cluster; protection of a user's own unlocked device; the security of the mower vendors' clouds, apps and firmware; availability under a determined denial-of-service attack beyond rate limiting.
| Asset | Where it lives | Why it matters |
|---|---|---|
| Account data: mowers, maps, zones, schedules, telemetry and position history, camera frames | PostgreSQL, one database per service (ADR 0006) | The household's private data; positions tell when nobody is home |
| Mower control | Fleet, Device-Gateway, the vendor clouds and the Bluetooth bridge | A moving mower with blades is a physical hazard (ADR 0012) |
| User credentials: password hashes, passkey public keys, refresh tokens, API tokens, device cookies | Identity's database. Passwords are stored as Argon2id hashes and tokens as SHA-256 of random values | Account takeover |
| Linked vendor credentials (Mammotion, Husqvarna, Worx) and AI provider API keys | Fleet and Vision databases, AES-256-GCM at rest (ADR 0034) | Takeover of the user's vendor account, costs on the user's AI account |
| Server secrets: token signing keys, key-encryption keys, NATS passwords, OpenFGA key, VAPID/APNs keys | Identity's database (signing keys, encrypted), environment or secret files | Forging tokens, impersonating services |
| Release artifacts |
ghcr.io/lukislp/mowdeck, GitHub Releases |
Supply-chain compromise of every installation |
- Anonymous network client: reaches the gateway, the login and registration endpoints, the public demo, and a theft tracking share link (256-bit token, only its SHA-256 stored, at most 48 hours, revocable, ADR 0058).
- Signed-in user: has a valid session. Must not reach another account's data, and inside an account only what the user's role and grants allow (ADR 0014).
- API token (Home Assistant, scripts, personal access tokens): limited to the grants its creator gave it, never more than the creator holds (ADR 0064).
- Demo visitor: an anonymous user of the public demo with a throwaway sandbox (ADR 0068, ADR 0076).
- Compromised service or container inside the stack: limited by per-service NATS users and subject permissions, per-service databases, and the fact that every service checks the caller's token itself. It is partly out of scope (see the residual risks).
- Hostile vendor cloud or network peer: sends malformed or oversized frames to the adapters.
- Supply-chain attacker: a tampered dependency, action or image.
| # | Boundary | Controls at the boundary |
|---|---|---|
| TB1 | Internet or LAN to the gateway | Traefik is the only entry point (ADR 0008). TLS is terminated by the operator's reverse proxy, Tailscale or the Kubernetes ingress (Compose, HTTPS, Helm). Rate limits on the login, registration and token routes. Trusted proxies have to be configured explicitly (GATEWAY_TRUSTED_PROXIES). |
| TB2 | Browser to web app | Strict CSP built with script hashes and no inline styles, X-Frame-Options: DENY, nosniff, Referrer-Policy: same-origin, Permissions-Policy, COOP, Strict-Transport-Security (one year with subdomains, only on requests that reached the gateway over HTTPS, WEB_HSTS) (apps/web/nginx/security-headers.conf). The access token lives only in memory, the refresh token only in an HttpOnly, SameSite=Strict cookie on a narrow path (ADR 0021). |
| TB3 | Request to a service | Every service verifies the EdDSA access token locally against Identity's JWKS (mowdeck-auth: alg allow list, none and HMAC refused, kid required, issuer, audience and expiry checked) (ADR 0015). Request bodies are typed Pydantic models with bounds, and anything else is answered with a problem response. |
| TB4 | Account A to account B, user to user | OpenFGA checks on every object and every command kind (ADR 0001, ADR 0016). Queries are scoped to the principal's account. Every service has tests that request another account's data and expect a rejection. WebSocket subscriptions are checked per mower and re-checked periodically (ADR 0018). |
| TB5 | Service to service | NATS with one user per service and least-privilege subject permissions, generated from one reviewable map (ADR 0074). CI fails on any permission violation. Synchronous calls carry the caller's token or a service token. OpenFGA needs its preshared key. |
| TB6 | MowDeck to mower and vendor cloud | Vendor adapters in their own packages (ADR 0066). Every frame from a vendor is parsed into contracts and fuzzed. The Bluetooth bridge is an ESPHome proxy whose API is encrypted with its own key (ESP32 bridge). The controller enforces the safety rules for every adapter (ADR 0012). |
| TB7 | Services to user-chosen URLs | Web Push endpoints only on the allowed push service hosts, ntfy only on configured servers, AI provider URLs without private, loopback or single-label hosts on hosted installations (Vision public policy). |
| TB8 | Source to release | Required checks and signed commits on main, CodeQL, dependency review and Socket on every pull request, SHA-pinned actions behind harden-runner, keyless-signed images, chart and release files with SLSA provenance, a tag created by the lktec-bot GitHub App through the API (releases). |
| Threat | Example | Countermeasure | Evidence |
|---|---|---|---|
| Spoofing a user | Password guessing, phishing | Argon2id hashes, login throttling per address and per user, passkeys (origin-bound WebAuthn), GitHub and Apple sign-in with PKCE, alerts about new sign-ins (ADR 0050, ADR 0065) | Identity tests, services/identity/README.md
|
| Spoofing with a stolen session | A copied refresh token | Refresh tokens rotate on every use. A replayed old token ends the whole login session (reuse detection). Critical actions need a recent interactive login (step-up, ADR 0057) | ADR 0015, Identity tests |
| Spoofing a service | A container posing as Fleet on NATS | Per-service NATS users and subject permissions | ADR 0074, test_nats_auth_server.py
|
| Tampering with another account's mower | IDOR on /mowers/{id}
|
OpenFGA object checks; a foreign and a missing mower answer the same | Tenant isolation tests in every service |
| Tampering with commands | Replayed or stale drive command | Command envelopes with id, de-duplication and expiry; drive sessions with an explicit stop (ADR 0009, ADR 0026) |
libs/mowdeck-control tests |
| Tampering with releases | A malicious image | Sigstore signatures and provenance; verify-release.sh pins verified digests |
SECURITY.md |
| Repudiation | Who started the mower? | Commands carry the principal. Security events (logins, step-up, token changes) are logged per user and shown on the security page (ADR 0065) | Your security page |
| Information disclosure across accounts | Positions of another household | Account-scoped queries, OpenFGA checks, Cache-Control: no-store on authenticated responses |
mowdeck_service_kit.hardening |
| Disclosure of credentials | A database dump | Password hashes, SHA-256 token digests, AES-256-GCM for vendor credentials, AI keys and signing keys, with the row id as associated data. The development keys of .env.example are refused in production |
fleet.adapters.crypto, identity.adapters.crypto
|
| Disclosure through tokens in URLs | WebSocket token in access logs | The token travels in the first WebSocket message, never in the URL (ADR 0018) | Realtime tests |
| Denial of service | Login floods, oversized frames | Gateway rate limits, Identity throttling, bounded model fields, bounded caches and queues, demo sandbox limits per address | ADR 0076, load tests (load testing) |
| Elevation of privilege | An API token used for more than its grants | Tokens are their own OpenFGA subject with explicit grants, capped at the creator's rights; step-up endpoints refuse API tokens | ADR 0064 |
| Elevation in the demo | A visitor reaching out of the demo |
DemoModeMiddleware in every service refuses the listed mutations, with allow lists in Identity, Entitlements and Billing |
ADR 0068 |
| SSRF | A push endpoint pointing into the stack | Allowed push hosts, configured ntfy servers, the public URL policy of Vision | Notifications and Vision tests |
How the Saltzer and Schroeder principles show up in the code:
-
Economy of mechanism. One token format (EdDSA JWTs from Identity,
mowdeck-auth), one authorization service (OpenFGA), one service kit (mowdeck-service-kit) that every service is built on, and one controller that enforces the mower safety rules for every adapter. Crypto comes fromcryptography,argon2-cffiand PyJWT. There are no home-made primitives. -
Fail-safe defaults. A route needs a valid token unless it is explicitly public. A missing encryption key disables the feature (503) instead of storing plaintext. Services default to
MOWDECK_ENVIRONMENT=prod, the strictest setting. Map tiles, camera, AI analysis and other features that talk to third parties are off by default. Unknownalgvalues and tokens withoutkidare refused. - Complete mediation. Every HTTP request and every WebSocket subscription is checked against OpenFGA, per object. Open WebSocket subscriptions are re-checked every 60 seconds and right after a revocation event from Identity (ADR 0018).
- Open design. The whole system is public, including this threat model. Security does not depend on secrecy of the code.
- Separation of privilege. Critical actions need a confirmation with a passkey or the password within the last few minutes (step-up, ADR 0057), and an API token is never enough for them.
-
Least privilege. Per-service NATS users with the subjects they need and nothing else. Per-service databases. API tokens get only the grants chosen for them. Containers run without capabilities and read-only where possible (Compose
cap_drop/read_only, HelmrunAsNonRoot,readOnlyRootFilesystem,drop: ["ALL"], seccompRuntimeDefault). - Least common mechanism. Refresh tokens, API tokens, device cookies, relay tickets and tracking links are separate secret spaces with their own prefixes and storage, so one can never be used as another.
- Psychological acceptability. Passkeys and provider sign-in besides passwords. Pairing Home Assistant needs a single token. The security page shows signed-in devices and recent events in plain language.
| Category | Countermeasure |
|---|---|
| A01 Broken access control | OpenFGA per object and command kind, account-scoped queries, tenant isolation tests in every service, step-up for critical actions |
| A02 Cryptographic failures | Only published algorithms: Ed25519 (EdDSA) signatures, AES-256-GCM, Argon2id, SHA-256, HMAC comparisons in constant time. secrets/os.urandom for every random value. No MD5, SHA-1, DES or RC4 in the code. TLS at the edge and, with a private CA and verified certificates, on the hops inside the stack (Network encryption) |
| A03 Injection | SQLAlchemy with bound parameters, no string-built SQL. Svelte output encoding and a strict CSP. No shell execution with user input. ruff's bandit rules (S) and CodeQL on every pull request |
| A04 Insecure design | ADRs for every architecture decision, this page, the mower safety rules, bounded inputs |
| A05 Security misconfiguration | Hardening headers set by the web container and the service kit, hardened containers, the chart and the Compose files validated in CI, development keys refused in production |
| A06 Vulnerable and outdated components | Dependabot (pip, uv, npm, GitHub Actions), dependency review, Socket, OSV-Scanner exceptions documented in osv-scanner.toml, OpenSSF Scorecard |
| A07 Identification and authentication failures | Argon2id, throttling, passkeys, rotating refresh tokens with reuse detection, 10 minute access tokens, new-login alerts |
| A08 Software and data integrity failures | Locked dependencies (uv.lock, pnpm-lock.yaml), SHA-pinned actions, signed commits required on main, signed releases with provenance |
| A09 Logging and monitoring failures | Structured logs, security events per user, metrics and traces (OpenTelemetry), health endpoints (ADR 0077) |
| A10 SSRF | Allow lists for push and ntfy, the public URL policy of Vision; map tile and aerial photo URLs are operator configuration, not user input |
Static analysis checks for these classes of bugs continuously: CodeQL (default setup for Python, JavaScript/TypeScript and Actions, a ruleset gate for high and critical alerts), ruff with the bandit rules, mypy strict, ESLint and svelte-check.
Every input from a potentially untrusted source is checked before use. Invalid input is rejected, not repaired:
-
Shape and type. Every HTTP body and query parameter binds to a typed Pydantic model or a typed parameter. Fields carry bounds (
ge,le,max_length, patterns, literal enums). FastAPI answers anything that does not fit with a problem response before the route runs. - Semantics. Services check what a model cannot express (time ranges, polygon validity, that a mower belongs to the account) and answer with a typed problem.
-
Messages. NATS and WebSocket messages are discriminated unions of
mowdeck-contractswith JSON Schemas. Unknown types are refused. - Vendor data. Frames from vendor clouds and the Bluetooth bridge are parsed into contracts by the adapters. The parsers are fuzzed continuously (fuzzing).
- URLs. User-chosen URLs pass an allow list or the public URL policy (TB7).
-
Credentials. Credentials are looked up by hash or compared with
hmac.compare_digest, and never parsed beyond format checks.
The house rule for new code: a new route or model field gets bounds in the model or an explicit check in the service, and a test for the rejection (CONTRIBUTING.md).
The algorithms are listed under A02. Algorithm agility is limited but real. Fleet's stored vendor credentials carry a format version and a key id (version || key id || nonce || ciphertext), so its key rotates without a rebuild (old keys stay readable from MOWDECK_CREDENTIAL_ENCRYPTION_KEY_PREVIOUS), and the next read re-seals a credential with the new key. Identity's signing keys and Vision's API keys are bound to their row by the associated data but carry no format version yet, so a new cipher for them needs a migration. Signing keys rotate on a schedule with an announced next key, and tokens name their key by kid. The accepted alg is an explicit allow list in mowdeck-auth. TLS versions and cipher suites at the edge are those of the operator's proxy or ingress; inside the stack every server and client allows TLS 1.2 or newer with the libraries' default suites (TLS 1.3 in practice), and the internal certificates use ECDSA P-256 with SHA-256. Keys, secrets and certificates are never compiled in: they come from the environment or secret files and can be rotated without a rebuild.
The edge (browser and apps to the gateway) uses TLS terminated by the operator's proxy, Tailscale or the ingress. Passkeys, Web Push and the PWA need it, and the documentation says so (Compose, HTTPS). AI providers are called over HTTPS. Vendor clouds are reached through the vendor libraries and the vendors' own endpoints. Nothing in the repository turns certificate verification off.
Inside the stack every hop is encrypted as well (internal TLS), on by default and without a setting: Traefik to the services and the web app, service to service, the services to NATS, PostgreSQL and OpenFGA, OpenFGA to PostgreSQL, and the camera relay's control and WHEP paths. The Compose job tls-init creates a private CA on the first start and one certificate per server, renews them, and hands each key only to the user of the container that reads it; the Helm chart takes them from cert-manager when it is installed or from a CA it generates and renews on helm upgrade (Helm, internal TLS). Every client checks the server's certificate and name against that CA (Traefik with one servers transport per service, Python with ssl.create_default_context, Go and Node with the CA as trust store), PostgreSQL refuses connections without TLS over the network (hostnossl ... reject), and NATS requires TLS on its client port. The hops stay authenticated on top (tokens, per-service NATS users, database passwords, the OpenFGA key). What stays plain is listed with its reason in internal TLS: a container's own loopback, the agora-bridge in camera-relay's network namespace, and the RTSP feed between camera-relay and MediaMTX (ffmpeg cannot verify an RTSPS certificate).
main only changes through pull requests that pass the required checks (python, web, docs, compose, helm, docker, e2e, e2e-web, camera-relay-media, openfga-model, dependency review, Socket) and the CodeQL gate. Commits must be signed. Dependencies are locked (uv.lock, pnpm-lock.yaml), actions are pinned to commit SHAs, third-party images are pinned in one list and mirrored. Bots go through the same gate. Updates of the mower vendor libraries need a hardware test before the merge (vendor library updates). Releases are produced only by the release workflow and signed keyless. The verification steps are in SECURITY.md.
| Risk | Why accepted | Mitigation |
|---|---|---|
The camera relay's RTSP feed to MediaMTX is not encrypted (profile relay) |
ffmpeg's RTSP client cannot verify an RTSPS server certificate | Publisher secret per process, reachable inside the stack's network only; moving MediaMTX into camera-relay's network namespace (loopback) is planned |
| No NetworkPolicies in the Helm chart | Not yet built | Every service authenticates its callers itself; on the roadmap |
| One shared password for the service database roles in Compose | Development default (risks and technical debt) | Databases reachable only inside the Compose network; on the roadmap |
| Traefik mounts the Docker socket read-only (Compose) | Needed for its Docker provider | Read-only mount, Traefik is a pinned upstream image |
| A revoked session's access token stays valid until it expires | Services verify tokens locally, without a call to Identity | Access tokens live 10 minutes; a revocation event to every service is on the roadmap (ADR 0065) |
| Vendor clouds and vendor libraries | Outside the project's control | Credentials encrypted at rest, every vendor frame parsed and fuzzed, library updates only after a hardware test |
| Single maintainer (bus factor 1) | No second person available | GOVERNANCE.md, fully public build |
| Date | Scope | By | Outcome |
|---|---|---|---|
| 2026 (phase gates 1 to 6) | OWASP ASVS chapters per phase gate (phases), the security ADRs (0015, 0021, 0057, 0064, 0065, 0074) | Maintainer (internal) | Findings fixed with regression tests, decisions recorded in the ADRs |
| 2026-11-04 | This assurance case, the OpenSSF Best Practices review: development keys, TLS verification, SSRF paths, token handling | Maintainer (internal) | Identity now refuses its development key-encryption key in production. No other finding. Residual risks listed above |
No external security review has taken place so far. The next internal review is due when a trust boundary changes, and at the latest after twelve months.
Mirrored from docs/dev/security-assurance.md at commit a89d0b645d910c323ec6264267d259a1d899fdf1. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit a89d0b645d910c323ec6264267d259a1d899fdf1. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
- ADR 0073: Guided tour with coach marks
- ADR 0074: NATS authentication and per-service subject permissions
- ADR 0075: Chat control as an optional assistant add-on
- ADR 0076: Demo sandboxes, one account per visitor
- ADR 0077: Liveness, readiness and routing
- ADR 0078: Batched state ingestion
- ADR 0079: Fast demo sandbox claim and release
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Security assurance case
- Fuzzing and property-based tests
- CI and pull request scope
- Reproducible builds
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Internal TLS
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Chat control (assistant add-on)
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Load testing
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Chat control
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)