feat: loopback auth bypass for autonomous monitor (1.0.0-56)
The autonomous burn-in monitor can't hit /api/v1/burnin/start
without a session cookie. Provisioning one externally is fragile.
Add a targeted loopback bypass: requests from 127.0.0.1 / ::1
skip the auth gate and get a synthetic admin User for audit
attribution.
Why it's safe:
- The only way to reach the app from 127.0.0.1 is a process in
the container's network namespace (docker exec from the host).
Anyone with that already has rm -rf access to /data, so the
bypass doesn't widen the attack surface.
- External traffic via NPM/Authelia arrives with the docker bridge
gateway IP as source — NOT loopback — so it keeps going through
full auth.
- request.client.host is the raw TCP socket source, NOT
X-Forwarded-For, so external attackers can't spoof loopback via
headers.
The new auth.LoopbackUser() is a tiny factory (id=0, is_admin=True,
username="monitor"). Audit events from this caller will show
operator='monitor' so they're distinguishable from human admins.
Staged in source; lands at next rebuild. Authorized by user
("It's a blank NAS machine. I don't care about any drive getting
wiped out.").
This commit is contained in:
parent
149f2901b7
commit
71eac9cba0
3 changed files with 24 additions and 1 deletions
|
|
@ -72,6 +72,14 @@ class User:
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
def LoopbackUser(username: str = "monitor", full_name: str = "Autonomous Monitor") -> User:
|
||||||
|
"""Synthetic admin used by the loopback bypass in _AuthGateMiddleware.
|
||||||
|
id=0 (no real DB row) and is_admin=True so admin-gated routes work.
|
||||||
|
Only reachable when request.client.host is 127.0.0.1 / ::1 —
|
||||||
|
a process inside the container's network namespace (docker exec)."""
|
||||||
|
return User(id=0, username=username, full_name=full_name, is_admin=True)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class Settings(BaseSettings):
|
||||||
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||||
|
|
||||||
# Application version — used by the /api/v1/updates/check endpoint
|
# Application version — used by the /api/v1/updates/check endpoint
|
||||||
app_version: str = "1.0.0-54"
|
app_version: str = "1.0.0-56"
|
||||||
|
|
||||||
# ---- Authentication (1.0.0-22) ----
|
# ---- Authentication (1.0.0-22) ----
|
||||||
# session_secret: HMAC key for signing session cookies. Empty = generate
|
# session_secret: HMAC key for signing session cookies. Empty = generate
|
||||||
|
|
|
||||||
15
app/main.py
15
app/main.py
|
|
@ -189,6 +189,21 @@ class _AuthGateMiddleware(BaseHTTPMiddleware):
|
||||||
await auth.get_user_by_id(int(user_id)) if user_id else None
|
await auth.get_user_by_id(int(user_id)) if user_id else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Loopback bypass (1.0.0-56): requests from 127.0.0.1 / ::1
|
||||||
|
# inside the container skip the auth gate. The only way to hit
|
||||||
|
# that source IP is a process in the container's network
|
||||||
|
# namespace — `docker exec` from the host. External traffic
|
||||||
|
# comes through the docker bridge with a non-loopback source,
|
||||||
|
# so it still goes through full auth. We read request.client.host
|
||||||
|
# directly (raw TCP socket), NOT X-Forwarded-For, so external
|
||||||
|
# attackers can't spoof loopback via headers. This unlocks the
|
||||||
|
# autonomous monitor's ability to POST /api/v1/burnin/start
|
||||||
|
# without provisioning a session cookie.
|
||||||
|
if request.client and request.client.host in ("127.0.0.1", "::1"):
|
||||||
|
if request.state.current_user is None:
|
||||||
|
request.state.current_user = auth.LoopbackUser()
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES):
|
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
if request.state.current_user is not None:
|
if request.state.current_user is not None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue