Files
stupid-simple-network-inven…/SECURITY_DECISIONS.md
T
olivier 88cf6458d0 Initial commit — Stupid Simple Network Inventory
Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker.
Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 09:19:19 +02:00

7.7 KiB

Security Decisions

Decisions, trade-offs, and documented limitations for Stupid Simple Network Inventory.


Decision: keep JWT in localStorage + Bearer token.

Rationale: migrating to HttpOnly cookies requires changes to the Nginx config (cookie proxying, SameSite handling), the FastAPI auth flow, and all frontend API calls. The added complexity is disproportionate for a self-hosted LAN tool. The risk is mitigated by:

  • 24-hour token expiry (reduced from 7 days)
  • token_version invalidation: password change immediately revokes all prior tokens
  • The application is designed for trusted LAN use, not public internet exposure

Assumption: the deployment is behind a trusted proxy or on a private network where XSS is the primary concern and is partially mitigated by the LAN context.

Future: if the app ever needs public internet exposure, migrating to HttpOnly; Secure; SameSite=Strict cookies should be the first priority.


Token versioning instead of a session store

Decision: token_version integer column on User, incremented on password change.

Rationale: no Redis, no external session store. A single integer per user provides immediate token invalidation with zero dependencies. The version is included in the JWT payload (ver field) and validated on every request.

Trade-off: revocation is per-user, not per-token. Logging out one device still lets other devices use their tokens until the common password is changed. This is acceptable for a single-user or small-team tool.

Limitation: ver absent from old tokens is treated as ver=1 for backward compatibility. Existing valid tokens (before token_version was added) will therefore continue to work until the user changes their password.


no-new-privileges absent from backend container

Decision: omit security_opt: no-new-privileges:true for the backend service.

Rationale: the discovery feature uses subprocess.run(["ping", ...]). In iputils-ping on Debian, the ping binary has the file capability cap_net_raw=ep. For a non-root process to execute ping successfully, the execve call must be allowed to promote capabilities from the file's permitted set. no-new-privileges blocks this promotion, breaking the ping feature.

Alternatives considered:

  • Ambient capabilities (Linux 4.3+): would require modifying the entrypoint to prctl(PR_CAP_AMBIENT_RAISE, ...) before exec. Adds complexity with no meaningful security gain for this threat model.
  • SUID ping: no-new-privileges also blocks SUID.
  • Replace subprocess ping with a Python ICMP implementation: significant refactor out of scope.

Mitigations in place: cap_drop: ALL + cap_add: NET_RAW DAC_OVERRIDE limits the container to only the capabilities needed. DAC_OVERRIDE is required because the db_data/ bind-mount is owned by the host user; root without it cannot create SQLite journal files in that directory. Both capabilities are in Docker's default set, so this is a net reduction from a standard container.


CORS default to *

Decision: ALLOWED_ORIGINS defaults to "*" for backward compatibility.

Rationale: the application is designed for same-origin access via Nginx (browser → Nginx → FastAPI), so CORS headers are not required for normal operation. However, changing the default to empty (no CORS) would silently break integrations where users access the API from a different origin. The default "*" maintains existing behavior while making it configurable.

Production recommendation: set ALLOWED_ORIGINS= (empty) if the backend is only accessed through the Nginx proxy, or set it to the specific domain if cross-origin access is needed.


In-memory rate limiting

Decision: Python dict[str, list[float]] protected by threading.Lock, no Redis.

Rationale: Redis adds operational complexity (another service, volume, potential failure mode). For a single-process Uvicorn deployment behind Nginx, in-memory rate limiting is sufficient. The Nginx limit_req module provides the primary protection at the network edge; the Python layer adds defense-in-depth.

Limitations:

  • State is lost on container restart (acceptable: a restart already breaks all active sessions).
  • Does not work across multiple workers. Uvicorn defaults to a single worker; if scaled, the Nginx layer remains effective.
  • IP tracking sees the Nginx container's IP (Docker internal), not the real client IP, unless X-Real-IP is forwarded to the backend. The Nginx limit_req uses the real client IP correctly.

APP_UID / APP_GID build args

Decision: configurable container UID/GID via build args, defaulting to 1000.

Rationale: the backend bind-mounts ./db_data:/app/data. For the non-root container user to write to this directory, the container UID must match the host directory owner. On most Linux systems, the first non-root user is UID 1000. The build args allow customisation without Dockerfile changes.

Alternative: use an entrypoint that chowns the directory at runtime (current implementation via entrypoint.sh). This ensures compatibility regardless of UID mismatch, at the cost of a brief root execution before dropping to appuser.


nginx-unprivileged for frontend container

Decision: use nginxinc/nginx-unprivileged:alpine as the frontend base image.

Rationale: the standard nginx:alpine image requires CAP_CHOWN and CAP_SETUID at startup — the master process (root) chowns temp directories and forks workers as nginx user (UID 101). With cap_drop: ALL, both syscalls fail. Two alternative approaches were tried and abandoned:

  • USER nginx in the Dockerfile: BusyBox sed does not support \s in basic regex mode, so the PID path substitution silently failed; the entrypoint scripts also need root.
  • gosu appuser in the backend: CAP_SETUID is dropped, so gosu cannot switch UIDs.

nginxinc/nginx-unprivileged:alpine is maintained by nginx Inc. and pre-configures nginx to run entirely as UID 101 without needing CAP_CHOWN or CAP_SETUID. The entire process tree (master + workers) runs as nginx, making cap_drop: ALL compatible with zero extra capabilities on the frontend.

Trade-off: adds a dependency on a third-party image (nginxinc/nginx-unprivileged) instead of the official nginx image. The image is maintained by nginx Inc. itself, so the trust model is equivalent.


ping and NET_RAW capability

Decision: retain cap_add: NET_RAW on the backend container.

Rationale: the discovery feature (/api/discovery/ping, /api/discovery/scan) uses ICMP ping, which requires CAP_NET_RAW. This is the minimum capability needed and is explicitly documented. The capability is dropped from the frontend container which has no need for it.


SQLite without foreign key enforcement

Status: known limitation, not addressed in this phase.

SQLite does not enforce foreign keys by default. Enabling PRAGMA foreign_keys=ON per connection via a SQLAlchemy event was out of scope for this phase. The application code manually handles cascades (explicit DELETE before device removal). This is documented in SEC-FIX-013 as a future fix.


Future security improvements (not scheduled)

ID Topic
SEC-FIX-006 Scope discovery scan to inventory CIDRs, validate IPs
SEC-FIX-007 Pydantic field constraints on business models
SEC-FIX-008 HTTP security headers (CSP, X-Content-Type-Options, etc.)
SEC-FIX-009 Document TLS / reverse-proxy contract more explicitly
SEC-FIX-012 Structured audit logging
SEC-FIX-013 SQLite foreign key enforcement
SEC-FIX-014 npm audit / pip-audit in CI
SEC-FIX-015 Import JSON schema validation and size limits
SEC-FIX-016 Remove v-html from App.vue