Skip to content

Commit 85c9261

Browse files
authored
Merge pull request #35 from gorecodes/develop
Develop
2 parents 01569b2 + 9bfcb26 commit 85c9261

9 files changed

Lines changed: 73 additions & 11 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ ARBOR_REMEDIATION_PLAN.md
3030
ARBOR_ARCHITECTURE_ROADMAP.md
3131
ONLINE_AUTH_HARDENING_PLAN.md
3232
PHASE1_PROGRESS.md
33+
FEATURE_PROPOSALS.md

README.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,43 @@ ARBOR_CORS_ORIGINS=https://arbor.lan:8443,https://192.168.1.10:8443
353353

354354
Then restart the services and open `https://<hostname>:8443`. You will need to accept the certificate warning unless you import the certificate into your browser trust store.
355355

356+
### Pattern C — plain HTTP on a VPN interface
357+
358+
If Arbor is reachable only through a VPN tunnel (e.g. WireGuard), the tunnel itself provides confidentiality and a self-signed certificate adds no real security. Use `ARBOR_ALLOW_PLAINTEXT=1` to opt out of the TLS requirement:
359+
360+
```bash
361+
ARBOR_HOST=0.0.0.0 # or the specific WireGuard interface IP
362+
ARBOR_PORT=8444
363+
ARBOR_TLS=0
364+
ARBOR_ALLOW_PLAINTEXT=1 # opt-in: VPN tunnel provides confidentiality
365+
ARBOR_CORS_ORIGINS=http://10.0.0.1:8444
366+
```
367+
368+
Arbor prints a startup warning. This mode is **only safe** when the bind address is reachable exclusively through a trusted private network or VPN — do not combine it with a public interface.
369+
370+
If you also want to keep the existing HTTPS reverse-proxy path (e.g. for access via a public domain), run two separate Arbor instances on different ports, or keep Apache terminating TLS on the public side while Arbor binds to the WireGuard IP directly.
371+
372+
**Apache HTTP proxy in front of the VPN path** (optional, e.g. to share port 80 with other services):
373+
374+
```apache
375+
<VirtualHost *:80>
376+
ServerName 10.0.0.1
377+
ProxyPreserveHost On
378+
RequestHeader set X-Forwarded-Proto "http"
379+
380+
ProxyPass /ws/ ws://127.0.0.1:8444/ws/
381+
ProxyPassReverse /ws/ http://127.0.0.1:8444/ws/
382+
383+
ProxyPass /api/ http://127.0.0.1:8444/api/
384+
ProxyPassReverse /api/ http://127.0.0.1:8444/api/
385+
386+
ProxyPass / http://127.0.0.1:8444/
387+
ProxyPassReverse / http://127.0.0.1:8444/
388+
</VirtualHost>
389+
```
390+
391+
Do **not** add `ProxyPassReverseCookiePath` — it rewrites the cookie `Path` attribute, which causes the browser to hold multiple cookies with the same name and breaks CSRF validation. If you previously had this directive and see CSRF errors after removing it, clear all `arbor_*` cookies in the browser and re-login.
392+
356393
### After any LAN config change
357394

358395
- Sessions made before the change carry the old cookie attributes — log out + log in again to pick up `SameSite=Strict` and the CSRF cookie.
@@ -382,7 +419,7 @@ Then restart the services and open `https://<hostname>:8443`. You will need to a
382419
| `ARBOR_IPC_KEY_FILE` | `/etc/arbor/ipc.key` | Shared HMAC key file, generated by setup by default |
383420
| `ARBOR_IPC_ALLOWED_UIDS` | uid of user `arbor` | Comma-separated peer uid allowlist for `/run/arbor/daemon.sock`. Anything outside this set is rejected via `SO_PEERCRED`. |
384421
| `ARBOR_TRUSTED_PROXIES` | `127.0.0.1` | Comma-separated IPs passed to uvicorn's `forwarded_allow_ips`. Controls which proxy addresses are trusted to set `X-Forwarded-For` / `X-Forwarded-Proto`. |
385-
| `ARBOR_ALLOW_PLAINTEXT` | unset | Allow plain HTTP on **loopback only** when `ARBOR_TLS` is unset and cert/key are missing. Refused on public binds. |
422+
| `ARBOR_ALLOW_PLAINTEXT` | unset | Allow plain HTTP on non-loopback interfaces (e.g. a WireGuard VPN address). Set to `1` alongside `ARBOR_HOST=0.0.0.0` (or a specific VPN IP) and `ARBOR_TLS=0`. Only safe when the network itself provides confidentiality (VPN tunnel). A startup warning is printed. |
386423
| `ARBOR_CORS_ORIGINS` | loopback `http(s)` on `localhost`, `127.0.0.1`, `[::1]` (port `8443`) | Comma-separated allowed origins |
387424
| `ARBOR_STATIC_DIR` | auto-detected | Override the frontend static directory |
388425

@@ -451,9 +488,9 @@ Arbor is still an early-release, local-first admin tool. The default install bin
451488
### Web edge
452489

453490
- **CSRF**: every state-changing request (`POST`, `PUT`, `DELETE`, `PATCH`) requires a matching `X-CSRF-Token` header that echoes the `arbor_csrf` cookie. The cookie is set at login, rotated on logout/TOTP changes, and verified by middleware before the handler runs. The first WebSocket auth frame must include the same token. The login endpoint itself is the only exemption.
454-
- **Cookies**: both `arbor_session` (HttpOnly) and `arbor_csrf` are `Secure` and `SameSite=Strict`. Cross-site navigations no longer carry the session, which closes most CSRF vectors at the browser level.
491+
- **Cookies**: both `arbor_session` (HttpOnly) and `arbor_csrf` are `SameSite=Strict`. The `Secure` flag is set when the request is served over HTTPS (either Arbor's own TLS or via a reverse proxy that sets `X-Forwarded-Proto: https`); on plain HTTP (e.g. VPN access with `ARBOR_ALLOW_PLAINTEXT=1`) cookies are set without `Secure` so the browser accepts and returns them. Cross-site navigations no longer carry the session, which closes most CSRF vectors at the browser level.
455492
- **HSTS**: emitted as `Strict-Transport-Security: max-age=63072000; includeSubDomains` when the request is served over HTTPS (including via a reverse proxy that sets `X-Forwarded-Proto: https`).
456-
- **TLS bind enforcement**: a non-loopback bind (`ARBOR_HOST` other than `127.0.0.1`/`::1`/`localhost`) requires TLS to be active. Arbor refuses to start in plain HTTP on a public interface.
493+
- **TLS bind enforcement**: a non-loopback bind (`ARBOR_HOST` other than `127.0.0.1`/`::1`/`localhost`) requires TLS to be active by default. Arbor refuses to start in plain HTTP on a public interface unless `ARBOR_ALLOW_PLAINTEXT=1` is set (opt-in escape hatch for VPN/private network deployments where the tunnel itself provides confidentiality).
457494
- **WebSocket origin**: a missing `Origin` header is accepted only when bound to loopback. On any public bind, the connecting `Origin` must be in `ARBOR_CORS_ORIGINS`.
458495
- **Security response headers**: a strict CSP (`script-src 'self'`, `object-src 'none'`, `frame-ancestors 'none'`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. `/docs`, `/redoc`, and `/openapi.json` are disabled.
459496

backend/arbor/csrf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ def generate_csrf_token() -> str:
3030
return secrets.token_urlsafe(_TOKEN_BYTES)
3131

3232

33-
def set_csrf_cookie(response: Response, token: str) -> None:
33+
def set_csrf_cookie(response: Response, token: str, *, secure: bool = True) -> None:
3434
response.set_cookie(
3535
key=CSRF_COOKIE_NAME,
3636
value=token,
3737
httponly=False,
38-
secure=True,
38+
secure=secure,
3939
samesite="strict",
4040
max_age=session_ttl_seconds(),
4141
path="/",

backend/arbor/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,9 @@ async def auth_login(request: Request):
259259
"step_up_method": session.get("step_up_method", ""),
260260
},
261261
)
262-
set_session_cookie(response, session["session_id"])
263-
set_csrf_cookie(response, generate_csrf_token())
262+
_secure = getattr(getattr(request, "url", None), "scheme", "http") == "https"
263+
set_session_cookie(response, session["session_id"], secure=_secure)
264+
set_csrf_cookie(response, generate_csrf_token(), secure=_secure)
264265
return response
265266

266267

backend/arbor/server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@ def _is_loopback_host(host: str) -> bool:
2929
def _enforce_loopback_or_tls(host: str, tls: bool) -> None:
3030
if tls or _is_loopback_host(host):
3131
return
32+
if env_value("ARBOR_ALLOW_PLAINTEXT") == "1":
33+
print(
34+
f"[arbor] WARNING: ARBOR_ALLOW_PLAINTEXT=1 — plain HTTP on {host!r}. "
35+
"Only safe behind a VPN or trusted private network.",
36+
flush=True,
37+
)
38+
return
3239
print(
3340
f"[arbor] ERROR: refusing to bind {host!r} without TLS. Plain HTTP is only\n"
3441
f"[arbor] permitted on loopback (127.0.0.1, ::1, localhost). Provide a\n"
3542
f"[arbor] TLS certificate (ARBOR_CERT, ARBOR_KEY) or place this instance\n"
36-
f"[arbor] behind a TLS-terminating reverse proxy and bind to 127.0.0.1.",
43+
f"[arbor] behind a TLS-terminating reverse proxy and bind to 127.0.0.1.\n"
44+
f"[arbor] To allow plain HTTP on a private/VPN interface set ARBOR_ALLOW_PLAINTEXT=1.",
3745
file=sys.stderr,
3846
)
3947
sys.exit(2)

backend/arbor/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,12 @@ def session_from_cookie_header(cookie_header: str | None) -> str:
276276
return morsel.value if morsel is not None else ""
277277

278278

279-
def set_session_cookie(response: Response, session_id: str) -> None:
279+
def set_session_cookie(response: Response, session_id: str, *, secure: bool = True) -> None:
280280
response.set_cookie(
281281
key=session_cookie_name(),
282282
value=session_id,
283283
httponly=True,
284-
secure=True,
284+
secure=secure,
285285
samesite="strict",
286286
max_age=session_ttl_seconds(),
287287
path="/",

backend/tests/test_phase7_local_auth_foundation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717

1818

1919
class FakeRequest:
20-
def __init__(self, body=None, *, cookies=None, headers=None, client_host="127.0.0.1"):
20+
def __init__(self, body=None, *, cookies=None, headers=None, client_host="127.0.0.1", scheme="http"):
2121
self._body = body
2222
self.cookies = cookies or {}
2323
self.headers = headers or {}
2424
self.client = SimpleNamespace(host=client_host)
25+
self.url = SimpleNamespace(scheme=scheme)
2526

2627
async def body(self):
2728
if self._body is None:

backend/tests/test_pr1_web_edge.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for PR 1 (hardening/web-edge): CSRF, HSTS, TLS bind, WS origin."""
22

3+
import os
34
import unittest
5+
import unittest.mock
46
from unittest.mock import patch
57

68
from fastapi.testclient import TestClient
@@ -146,6 +148,12 @@ def test_public_without_tls_exits_2(self):
146148
web_server._enforce_loopback_or_tls("192.168.1.10", tls=False)
147149
self.assertEqual(cm.exception.code, 2)
148150

151+
def test_public_without_tls_allowed_by_plaintext_override(self):
152+
with unittest.mock.patch.dict(os.environ, {"ARBOR_ALLOW_PLAINTEXT": "1"}):
153+
# No SystemExit expected — user explicitly opted in (e.g. behind VPN).
154+
web_server._enforce_loopback_or_tls("0.0.0.0", tls=False)
155+
web_server._enforce_loopback_or_tls("10.0.0.1", tls=False)
156+
149157

150158
if __name__ == "__main__":
151159
unittest.main()

config/arbor.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ ARBOR_TLS=0
1111
# ARBOR_CERT=/etc/arbor/cert.pem
1212
# ARBOR_KEY=/etc/arbor/key.pem
1313
# ARBOR_CORS_ORIGINS=https://arbor.lan
14+
#
15+
# VPN / private network: to allow plain HTTP on a non-loopback interface
16+
# (e.g. WireGuard 10.0.0.1) set both of the following:
17+
# ARBOR_HOST=0.0.0.0 (or the specific VPN interface IP)
18+
# ARBOR_ALLOW_PLAINTEXT=1
19+
# Only safe when the network itself provides confidentiality (VPN tunnel).
1420

1521
# --- Auth backend (do not change) ---
1622
ARBOR_AUTH_BACKEND=local

0 commit comments

Comments
 (0)