TALOS v0.3.0 Security Review¶
Date: April 2026 Scope: Full codebase assessment -- core API, agent, director, MQTT broker, infrastructure Classification: Internal -- Candid Assessment
1. Authentication & Session Management¶
Magic Link Flow¶
The login flow (core/main.py:435-461) issues a JWT-based magic link on POST /auth/login. Any email address is accepted with no validation -- the endpoint auto-creates a User record for unknown emails (line 438-442). This means:
- No email validation regex. The
dataparameter is a rawdict, not a Pydantic model with anEmailStrfield. Any string, including empty or malformed values, is stored as a user email. - No rate limiting. An attacker can flood
POST /auth/loginto generate unlimited magic links, triggering email sends via the Resend API (line 456-457). This is both an abuse vector (email bombing) and a cost risk. - Dev mode link leakage. When
RESEND_API_KEYis unset, the magic link is logged to stdout in plaintext (line 459). If production logs are ever misconfigured to omit the API key, tokens become visible in log aggregation.
The JWT token uses HS256 with a 10-minute expiry (MAGIC_LINK_EXPIRY_SECONDS = 600, line 64). The SECRET_KEY is mandatory at startup (line 57-61), which is good -- the app will refuse to boot without it.
Session Cookies¶
On successful verification (/auth/verify, line 464-488), the server sets a signed cookie:
resp.set_cookie(
"session_user",
signed_session,
httponly=True,
samesite="lax",
max_age=SESSION_COOKIE_MAX_AGE, # 86400 = 1 day
)
Finding: secure flag is not set. The cookie will be transmitted over plain HTTP. On any non-HTTPS deployment, this is a session hijacking vector via network sniffing. The secure=True flag must be added for production.
Session validation (get_current_user, line 251-259) uses itsdangerous.URLSafeTimedSerializer with max_age=86400. This is sound -- the cookie is both signed and time-bounded. There is no server-side session store, so logout (/auth/logout, line 491-495) only deletes the client cookie. A stolen cookie remains valid until natural expiry; there is no revocation mechanism.
Findings Summary¶
| Issue | Severity | Status |
|---|---|---|
No secure flag on session cookie |
High | Open |
| No email format validation on login | Medium | Open |
No rate limiting on /auth/login |
Medium | Open |
| No session revocation / server-side invalidation | Low | Open |
| Magic link logged to stdout in dev mode | Low | Acceptable (dev only) |
2. Authorization (RBAC)¶
Role Model¶
The RBAC system (core/main.py:265-308) defines three roles with a numeric hierarchy:
The require_role() dependency checks membership on every org-scoped API call. This is well-structured -- the dependency returns a context dict containing the user, org, and role, so endpoints cannot accidentally skip authorization.
Enforcement Audit¶
A grep of all require_role usages shows consistent enforcement:
- Viewer endpoints (read-only): station list (line 805), campaign list (line 939), assignment list (line 1064), campaign detail (line 1228)
- Operator endpoints (mutations): station provisioning (line 898), campaign CRUD (lines 965, 1166, 1198), assignment creation (line 1121)
- Owner endpoints: org settings (line 834)
Finding: No privilege escalation via role self-assignment. The InviteMemberRequest model (line 346-348) accepts a role string, but the invite endpoint is gated behind require_role("owner"). An operator cannot invite members with elevated roles.
Finding: Role value is not validated against allowed values. The Membership.role field (core/database.py:51) defaults to "operator" but has no database-level CHECK constraint or Pydantic enum validation. If an owner sends role: "superadmin", it would be stored and would fail the hierarchy check (defaulting to -1), effectively creating a locked-out member. This is a data integrity issue, not a privilege escalation, but it should be constrained.
Legacy Endpoints¶
The legacy /stations/provision endpoint (line 1360-1390) uses get_current_user directly instead of require_role(). It auto-assigns the station to the user's default org. This bypasses the org-scoped RBAC model and should be deprecated.
3. Input Validation¶
Pydantic Coverage¶
The shared/schemas.py file defines 20+ Pydantic v2 models for MQTT payloads. These provide type validation for MQTT messages (az/el as floats, freq as int, etc.). The CampaignCreate model (line 186-190) includes range validation on priority (ge=1, le=10).
However, the API request models in core/main.py (lines 341-374) are minimal Pydantic models with no field constraints:
CreateOrgRequest.name-- no max length, no character filteringCreateCampaignRequest.name-- no max lengthInviteMemberRequest.role-- no enum constraint (as noted in Section 2)StationProvision.network_id-- integer only, no bounds check
Finding: The login endpoint (POST /auth/login) accepts a raw dict, not a Pydantic model at all (line 436). This is the weakest point -- data["email"] would throw a KeyError (500 error) if the key is missing, rather than returning a proper 422.
SQL Injection¶
SQLModel/SQLAlchemy parameterized queries are used throughout. The _apply_safe_migrations() function in core/database.py (line 176-211) executes raw SQL via text(), but these are static strings with no user input interpolation. No SQL injection vectors identified.
XSS Considerations¶
The Jinja2 templates auto-escape by default (FastAPI/Starlette behavior). User-supplied values like org names and station names are rendered through template variables. No |safe filter abuse was observed in a search of the codebase, though a full template audit was not performed.
4. MQTT Security¶
Broker Authentication¶
This is the most critical security gap in the system.
The Mosquitto configuration (ops/mosquitto/config/mosquitto.conf, line 25-26) explicitly enables anonymous access:
Both the TCP listener (port 1883) and the WebSocket listener (port 9001) allow unauthenticated connections. The config file itself contains a comment (lines 5-8) acknowledging this is temporary and documenting the hardening steps, but as of v0.3.0 these have not been applied.
The Docker Compose file (ops/docker-compose.yml) passes MQTT_BACKEND_PASS and MQTT_DIRECTOR_PASS environment variables, and core/main.py (lines 97-100) does call username_pw_set() when credentials are available. But the broker ignores credentials when allow_anonymous true is set. The authentication infrastructure exists in the application layer but is not enforced at the broker.
Topic ACLs¶
There are no topic ACL rules configured. Any MQTT client can:
- Subscribe to all telemetry:
talos/gs/+/telemetry/#-- read real-time positions of all ground stations - Send commands to any station:
talos/gs/{station_id}/cmd/rot-- drive any rotator to arbitrary positions - Inject fake telemetry: publish to
talos/gs/{station_id}/telemetry/rot - Trigger system events: publish to
talos/system/refresh
Agent MQTT Client¶
The agent (agent/agent.py, line 99-105) connects to the broker with no authentication at all. The --key CLI argument is accepted (line 21) but never used -- it is parsed and discarded. There is no username_pw_set() call in the agent code. The API key generated during provisioning (line 914 of core/main.py) serves no security function.
WebSocket Exposure¶
The WebSocket listener on port 9001 is exposed in Docker Compose (line 33) and used by the browser dashboard. With anonymous access, any browser on the internet can connect and issue commands if the broker is publicly reachable.
Findings Summary¶
| Issue | Severity | Status |
|---|---|---|
| MQTT broker allows anonymous access | Critical | Open |
| No topic ACLs -- any client can command any station | Critical | Open |
Agent --key argument is unused (false sense of security) |
High | Open |
| No MQTT TLS (plaintext credentials and payloads) | High | Open |
| WebSocket listener publicly accessible without auth | High | Open |
5. Infrastructure Security¶
Docker Configuration¶
The production docker-compose.yml demonstrates good practices:
- Memory limits are set on all services (db: 512m, broker: 128m, core: 1g, director: 1g)
- Health checks are defined for all services with appropriate intervals
- Service dependencies use
condition: service_healthyfor ordered startup - Internal networking uses a dedicated
talos_netbridge network - Database not exposed -- only the dev override (
docker-compose.dev.yml) maps port 5432
Finding: MQTT ports are exposed to the host. Ports 1883 and 9001 are mapped in the base compose file (line 32-33). Combined with anonymous access, this makes the broker reachable from the host network.
Secret Management¶
The .env.example file (ops/.env.example) lists four secrets with changeme defaults. The actual .env file exists in the repo (ops/.env) -- this file should be in .gitignore.
The CI pipeline (gitlab-ci.yml, line 35) sets SECRET_KEY to a static test value: "ci_test_secret_key_not_for_production_use_1234567890". This is fine for CI but must never leak into production config.
Finding: Database credentials are hardcoded in core/database.py (line 15):
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://talos:talos_password@localhost:5432/talos_core")
The fallback contains a real-looking password. While this is overridden by environment variables in Docker, any accidental bare-metal run without env vars will connect with these credentials.
Fly.io Deployment¶
The CI pipeline deploys to Fly.io (lines 404-439). Fly.io handles TLS termination for HTTP services, and the broker config mentions this (found in fly/broker.toml). However, inter-service MQTT traffic within Fly's private network is unencrypted.
6. Supply Chain Security¶
CI Security Gates¶
The GitLab CI pipeline (.gitlab-ci.yml, lines 280-291) includes three GitLab-managed security templates:
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
These run in a dedicated security stage with needs: [] (parallel with other stages). This is a solid baseline:
- SAST -- static analysis for Python vulnerabilities
- Secret Detection -- scans for committed credentials, API keys, private keys
- Dependency Scanning -- checks for known CVEs in pip dependencies
Finding: Security scan results do not gate deployment. The build and deploy stages do not declare needs: dependencies on the security jobs. A pipeline can deploy even if SAST or dependency scanning finds critical issues. The sast job (line 281-285) is configured to not block.
Dependency Pinning¶
Requirements files were not audited in detail, but the CI installs dependencies with pip install -r requirements.txt without hash verification. A compromised PyPI package would be installed without detection beyond what dependency scanning catches.
7. Agent Security¶
Command Injection via Hamlib¶
The agent sends commands to rotctld over a raw TCP socket (agent/agent.py, line 82):
The :.2f format specifier constrains the output to numeric floats, which prevents command injection through the az/el values. If a non-numeric value is injected via MQTT, the format call would raise a ValueError before reaching the socket.
However, the cmd/config handler (line 67-68) passes payload["rotator"]["address"] directly to connect_rotator(), which splits on : and connects to that host/port. An attacker with MQTT access (trivial given anonymous broker access) could redirect the agent's rotator connection to an arbitrary host.
The cmd/rig handler (line 84-88) writes tuning data to stdout only -- no hardware interaction. The SDR integration appears to be a stub at v0.3.0.
Station Impersonation¶
Any MQTT client can publish to talos/gs/{station_id}/info and claim to be a station. There is no handshake, certificate pinning, or challenge-response authentication. An attacker who knows (or guesses) a station ID can inject telemetry, claim READY status, and receive commands.
Station IDs follow the pattern gs_{name}_{4-hex-chars} (line 913 of core/main.py), giving only 65,536 possibilities per station name -- easily enumerable.
8. Threat Model¶
Top 5 Threats (Ranked by Likelihood x Impact)¶
| # | Threat | Likelihood | Impact | Risk | Attack Vector |
|---|---|---|---|---|---|
| 1 | Unauthorized station control via MQTT | High | Critical | Critical | Connect to broker anonymously, publish to talos/gs/{id}/cmd/rot to drive rotators to arbitrary positions. Could cause physical damage to equipment or interfere with active tracking sessions. |
| 2 | Session hijacking via unencrypted cookies | Medium | High | High | On any HTTP (non-TLS) deployment, sniff network traffic to capture session_user cookie. Attacker gains full access to victim's account and all org memberships. |
| 3 | Telemetry spoofing / station impersonation | High | Medium | High | Publish fake telemetry to talos/gs/{id}/telemetry/rot. Director makes tracking decisions based on false position data, causing missed satellite passes or incorrect scheduling. |
| 4 | Email enumeration and account flooding | Medium | Medium | Medium | POST to /auth/login with arbitrary emails to create user accounts. No rate limit, no CAPTCHA. Pollutes user table and burns Resend API quota. |
| 5 | Rotator connection redirection | Medium | Medium | Medium | Via MQTT cmd/config, redirect an agent's rotator connection to an attacker-controlled host. The agent would then send all position commands to the attacker and receive crafted responses. |
9. Remediation Roadmap¶
P0 -- Critical (Target: Next Release)¶
| # | Fix | Effort | Details |
|---|---|---|---|
| 1 | Enable MQTT authentication | 2-4 hours | Set allow_anonymous false in mosquitto.conf, generate password file with mosquitto_passwd, configure all clients (core, director, agent) to authenticate. The env vars and application code already support this. |
| 2 | Implement MQTT topic ACLs | 4-8 hours | Create an ACL file restricting agents to talos/gs/{their_station_id}/#. Director gets read/write on talos/#. Dashboard gets read-only on telemetry topics. Use Mosquitto's acl_file directive. |
| 3 | Wire up agent API key authentication | 2-4 hours | The --key argument is already parsed. Use it as the MQTT password. On the broker side, validate against keys stored in the password file or via an auth plugin querying the core database. |
P1 -- High (Target: v0.4.0)¶
| # | Fix | Effort | Details |
|---|---|---|---|
| 4 | Add secure=True to session cookie |
15 minutes | In core/main.py:481, add secure=True to set_cookie(). Requires HTTPS in production (already handled by Fly.io TLS termination). |
| 5 | Enable MQTT TLS | 4-8 hours | Configure Mosquitto with server certificates on port 8883. Update all clients to use TLS connections. For Fly.io, this may be handled by the platform's TLS termination. |
| 6 | Add rate limiting to auth endpoints | 2-4 hours | Use slowapi or a custom dependency to limit /auth/login to 5 requests per minute per IP. Prevents email bombing and account flooding. |
| 7 | Validate email format on login | 30 minutes | Replace the raw dict parameter on /auth/login with a Pydantic model containing an EmailStr field. Add pydantic[email] to requirements. |
| 8 | Gate deployments on security scans | 1 hour | Add needs: [sast] to the build stage in .gitlab-ci.yml so deployments are blocked when SAST finds critical issues. |
P2 -- Medium (Target: v0.5.0)¶
| # | Fix | Effort | Details |
|---|---|---|---|
| 9 | Constrain role values | 1 hour | Add a CHECK constraint on Membership.role in the database and use a Literal["viewer", "operator", "owner"] type in the invite model. |
| 10 | Add input length constraints | 1-2 hours | Add max_length to all string fields in API request models (org name, campaign name, etc.). Prevents storage abuse. |
| 11 | Deprecate legacy endpoints | 2-4 hours | Remove /stations/provision and /missions/add which bypass org-scoped RBAC. Redirect users to the /api/orgs/{slug}/ equivalents. |
| 12 | Server-side session store | 4-8 hours | Replace cookie-only sessions with a Redis or DB-backed session store. Enables instant logout/revocation and concurrent session limits. |
| 13 | Pin dependency hashes | 2 hours | Use pip-compile --generate-hashes to lock all dependencies with cryptographic verification. |
Appendix: Files Reviewed¶
| File | Purpose |
|---|---|
core/main.py |
FastAPI application, auth, RBAC, all API endpoints |
core/database.py |
SQLModel ORM definitions, migrations |
agent/agent.py |
Ground station agent, MQTT client, Hamlib interface |
shared/schemas.py |
Pydantic v2 models for MQTT payloads |
ops/docker-compose.yml |
Production Docker Compose |
ops/docker-compose.dev.yml |
Development overrides |
ops/.env.example |
Environment variable template |
ops/mosquitto/config/mosquitto.conf |
Mosquitto broker configuration |
.gitlab-ci.yml |
CI/CD pipeline with security scanning |