diff --git a/README.md b/README.md
index 8e650d2..72de6a6 100644
--- a/README.md
+++ b/README.md
@@ -92,13 +92,20 @@ Firegex can also be restricted to accept connections only from a set of trusted
### Reverse proxy without Firegex authentication
-To delegate access control entirely to a reverse proxy, start Firegex with `--disable-auth`:
+To delegate access control entirely to a reverse proxy, start Firegex with `--unsafe-disable-auth`:
```bash
-python3 run.py start --host 127.0.0.1 --disable-auth
+python3 run.py start --host 127.0.0.1 --unsafe-disable-auth -P 'a-password-for-later'
```
-This disables Firegex's password, JWT, and Socket.IO authentication, and it does not ask for an initial password. Requests forwarded by the proxy, including headers such as `X-Forwarded-For`, reach Firegex normally. This grants full administrative access to every request that reaches Firegex, so bind it to loopback, a Unix socket, or otherwise restrict direct access to the proxy. Use `--no-disable-auth` to turn the built-in authentication back on.
+This disables Firegex's password, JWT, and Socket.IO authentication: **every request that reaches Firegex is a full administrator**, so bind it to loopback, a Unix socket, or otherwise make sure nothing but the proxy can reach it. Requests forwarded by the proxy, including headers such as `X-Forwarded-For`, reach Firegex normally. Use `--no-unsafe-disable-auth` to turn the built-in authentication back on.
+
+A few things worth knowing before using it:
+
+- **The setting is persisted** in `.firegex-conf.json`, like `--port` or `--allowed-ips`: a later plain `python3 run.py start` keeps authentication disabled until you pass `--no-unsafe-disable-auth`. Every start prints a banner while it is active, and `python3 run.py config --show` reports it.
+- **Set a password anyway** (`-P`, or later `python3 run.py config --password`). It is unused while authentication is disabled, but it is what you fall back on if you re-enable it. Without one, `--no-unsafe-disable-auth` brings Firegex up in its initial-setup state, where anyone who can reach it chooses the password.
+- **The password cannot be changed over the API** while authentication is disabled (`/api/login`, `/api/set-password` and `/api/change-password` answer `403`) — otherwise an anonymous caller could plant a credential that keeps working once authentication is back on. `python3 run.py config --password` still works, since it writes to the database from the host.
+- **`--allowed-ips` is not a substitute for the proxy's access control.** Combined with `--proxy-ip-header` it trusts a client-supplied header, so it only holds up if the proxy overwrites that header and nothing else can reach the port directly.
## Documentation
diff --git a/backend/app.py b/backend/app.py
index 26660f6..66778e4 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -39,7 +39,12 @@ async def lifespan(app):
ALLOWED_NETWORKS = [ip_network(ip.strip(), strict=False) for ip in os.getenv("ALLOWED_IPS", "").split(",") if ip.strip()]
PROXY_IP_HEADER = os.getenv("PROXY_IP_HEADER", "")
-DISABLE_AUTH = os.getenv("DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"}
+UNSAFE_DISABLE_AUTH = os.getenv("UNSAFE_DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"}
+# With UNSAFE_DISABLE_AUTH every request is already a full administrator, so the password
+# endpoints would let an anonymous caller plant a credential that keeps working once
+# authentication is turned back on. The password can only be set out of band, from the
+# host, with "python3 run.py config --password".
+AUTH_DISABLED_DETAIL = "Firegex authentication is disabled: the password can only be changed from the host with 'python3 run.py config --password'"
class IPFilterMiddleware:
def __init__(self, app):
@@ -117,7 +122,7 @@ utils.socketio = socketio.AsyncServer(
sio_app = socketio.ASGIApp(utils.socketio, socketio_path="/sock/socket.io", other_asgi_app=app)
app.mount("/sock", sio_app)
-def APP_STATUS(): return "run" if DISABLE_AUTH or db.get("password") is not None else "init"
+def APP_STATUS(): return "run" if UNSAFE_DISABLE_AUTH or db.get("password") is not None else "init"
def JWT_SECRET(): return db.get("secret")
def _hash_psw_sync(psw: str) -> str:
@@ -151,7 +156,7 @@ async def refresh_frontend(additional:list[str]=[]):
await socketio_emit([]+additional)
async def check_login(token: str = Depends(oauth2_scheme)):
- if DISABLE_AUTH:
+ if UNSAFE_DISABLE_AUTH:
return True
if not token:
return False
@@ -164,7 +169,7 @@ async def check_login(token: str = Depends(oauth2_scheme)):
@utils.socketio.on("connect")
async def sio_connect(sid, environ, auth):
- if not DISABLE_AUTH and (not auth or not await check_login(auth.get("token"))):
+ if not UNSAFE_DISABLE_AUTH and (not auth or not await check_login(auth.get("token"))):
raise ConnectionRefusedError("Unauthorized")
utils.sid_list.add(sid)
@@ -201,12 +206,15 @@ async def get_app_status(auth: bool = Depends(check_login)):
return {
"status": APP_STATUS(),
"loggined": auth,
- "version": API_VERSION
+ "version": API_VERSION,
+ "auth_disabled": UNSAFE_DISABLE_AUTH
}
@app.post("/api/login")
async def login_api(form: OAuth2PasswordRequestForm = Depends()):
"""Get a login token to use the firegex api"""
+ if UNSAFE_DISABLE_AUTH:
+ raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL)
if APP_STATUS() != "run":
raise HTTPException(status_code=400)
if form.password == "":
@@ -220,6 +228,8 @@ async def login_api(form: OAuth2PasswordRequestForm = Depends()):
@app.post('/api/set-password', response_model=ChangePasswordModel)
async def set_password(form: PasswordForm):
"""Set the password of firegex"""
+ if UNSAFE_DISABLE_AUTH:
+ raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL)
if APP_STATUS() != "init":
raise HTTPException(status_code=400)
if form.password == "":
@@ -231,6 +241,8 @@ async def set_password(form: PasswordForm):
@api.post('/change-password', response_model=ChangePasswordModel)
async def change_password(form: PasswordChangeForm):
"""Change the password of firegex"""
+ if UNSAFE_DISABLE_AUTH:
+ raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL)
if APP_STATUS() != "run":
raise HTTPException(status_code=400)
diff --git a/backend/utils/models.py b/backend/utils/models.py
index 962c0b1..7e9745b 100644
--- a/backend/utils/models.py
+++ b/backend/utils/models.py
@@ -7,6 +7,7 @@ class StatusModel(BaseModel):
status: str
loggined: bool
version: str
+ auth_disabled: bool = False
class PasswordForm(BaseModel):
password: str
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 05e49b9..1bc3a66 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -52,7 +52,7 @@ function App() {
const [loadinBtn, setLoadingBtn] = useState(false);
const queryClient = useQueryClient()
const { access_token } = useAuthStore()
- const { setVersion } = useSystemStore()
+ const { setVersion, setAuthDisabled } = useSystemStore()
useEffect(() => {
socketio.auth = { token: access_token || "" }
@@ -78,6 +78,7 @@ function App() {
getstatus().then(res => {
setSystemStatus(res)
setVersion(res.version || "unknown")
+ setAuthDisabled(res.auth_disabled === true)
if (!res.loggined && useAuthStore.getState().getAccessToken()) {
useAuthStore.getState().clearAccessToken()
}
diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx
index b217d5c..93d4349 100644
--- a/frontend/src/components/Header/index.tsx
+++ b/frontend/src/components/Header/index.tsx
@@ -16,7 +16,7 @@ function HeaderPage(props: any) {
const navigator = useNavigate()
const { navOpened, toggleNav } = useNavbarStore()
- const { version } = useSystemStore()
+ const { version, authDisabled } = useSystemStore()
const logout_action = () => {
logout().then(r => {
@@ -56,9 +56,11 @@ function HeaderPage(props: any) {
- Firewall Access
- } onClick={() => setChangePasswordModal(true)}>Change Password
-
+ {!authDisabled && <>
+ Firewall Access
+ } onClick={() => setChangePasswordModal(true)}>Change Password
+
+ >}
Actions
} onClick={() => setResetFiregexModal(true)}>Reset Firegex
@@ -70,10 +72,10 @@ function HeaderPage(props: any) {
-
+ {!authDisabled &&
-
+ }
setChangePasswordModal(false)} />
setResetFiregexModal(false)} />
diff --git a/frontend/src/js/models.ts b/frontend/src/js/models.ts
index 03bd8ee..133bd88 100644
--- a/frontend/src/js/models.ts
+++ b/frontend/src/js/models.ts
@@ -16,7 +16,8 @@ export type LoginResponse = {
export type ServerStatusResponse = {
status:string,
loggined:boolean,
- version?:string
+ version?:string,
+ auth_disabled?:boolean
}
export type PasswordSend = {
diff --git a/frontend/src/js/store.ts b/frontend/src/js/store.ts
index fb0f006..801bcb7 100644
--- a/frontend/src/js/store.ts
+++ b/frontend/src/js/store.ts
@@ -77,9 +77,13 @@ export const useSession = () => {
interface SystemState {
version: string | null;
setVersion: (version: string | null) => void;
+ authDisabled: boolean;
+ setAuthDisabled: (authDisabled: boolean) => void;
}
export const useSystemStore = create()((set) => ({
version: null,
setVersion: (version) => set({ version }),
+ authDisabled: false,
+ setAuthDisabled: (authDisabled) => set({ authDisabled }),
}));
diff --git a/run.py b/run.py
index 10e2604..ca653d4 100755
--- a/run.py
+++ b/run.py
@@ -106,7 +106,7 @@ def load_config():
"socket_dir": None,
"allowed_ips": None,
"proxy_ip_header": None,
- "disable_auth": False,
+ "unsafe_disable_auth": False,
}
if os.path.isfile(g.configfile):
@@ -168,7 +168,7 @@ def gen_args(args_to_parse: list[str]|None = None):
parser_start.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"])
parser_start.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips"))
parser_start.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header"))
- parser_start.add_argument('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Disable Firegex password/JWT authentication (for a trusted reverse proxy only)')
+ parser_start.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=config.get("unsafe_disable_auth", False), help='UNSAFE: disable Firegex password/JWT authentication, making every request that reaches firegex a full administrator (for a trusted reverse proxy only)')
parser_start.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False)
parser_start.add_argument('--version', '-v', required=False, type=str , help='Version of the firegex image to use', default=None)
parser_start.add_argument('--prebuilt', required=False, action="store_true", help='Use prebuilt docker image', default=False)
@@ -185,7 +185,7 @@ def gen_args(args_to_parse: list[str]|None = None):
parser_restart.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"])
parser_restart.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips"))
parser_restart.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header"))
- parser_restart.add_argument('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Disable Firegex password/JWT authentication (for a trusted reverse proxy only)')
+ parser_restart.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=config.get("unsafe_disable_auth", False), help='UNSAFE: disable Firegex password/JWT authentication, making every request that reaches firegex a full administrator (for a trusted reverse proxy only)')
parser_restart.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False)
parser_restart.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False)
@@ -204,7 +204,7 @@ def gen_args(args_to_parse: list[str]|None = None):
parser_config.add_argument('--password', required=False, type=str, nargs='?', const='', help='Change the password of the firewall (omit the value to be prompted for it interactively)')
parser_config.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips"))
parser_config.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header"))
- parser_config.add_argument('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Persist whether Firegex password/JWT authentication is disabled')
+ parser_config.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=None, help='UNSAFE: persist whether Firegex password/JWT authentication is disabled (applied on the next start/restart)')
parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False)
args = parser.parse_args(args=args_to_parse)
@@ -246,10 +246,10 @@ def gen_args(args_to_parse: list[str]|None = None):
# Save configuration if values were specified via command line and differ from config
config_changed = False
- if hasattr(args, 'port') and args.port != config["port"]:
+ if hasattr(args, 'port') and args.port is not None and args.port != config["port"]:
config["port"] = args.port
config_changed = True
- if hasattr(args, 'host') and args.host != config["host"]:
+ if hasattr(args, 'host') and args.host is not None and args.host != config["host"]:
config["host"] = args.host
config_changed = True
if hasattr(args, 'socket_dir') and args.socket_dir != config["socket_dir"]:
@@ -261,8 +261,8 @@ def gen_args(args_to_parse: list[str]|None = None):
if hasattr(args, 'proxy_ip_header') and args.proxy_ip_header != config.get("proxy_ip_header"):
config["proxy_ip_header"] = args.proxy_ip_header
config_changed = True
- if hasattr(args, 'disable_auth') and args.disable_auth != config.get("disable_auth", False):
- config["disable_auth"] = args.disable_auth
+ if getattr(args, 'unsafe_disable_auth', None) is not None and args.unsafe_disable_auth != config.get("unsafe_disable_auth", False):
+ config["unsafe_disable_auth"] = args.unsafe_disable_auth
config_changed = True
if config_changed:
@@ -368,7 +368,7 @@ def write_compose(skip_password = True):
*([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() else []),
*([f"ALLOWED_IPS={args.allowed_ips}"] if getattr(args, 'allowed_ips', None) else []),
*([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []),
- *(["DISABLE_AUTH=1"] if getattr(args, 'disable_auth', False) else [])
+ *(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_disable_auth', False) else [])
],
"volumes": [
"firegex_data:/execute/db",
@@ -425,7 +425,7 @@ def write_compose(skip_password = True):
*([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() else []),
*([f"ALLOWED_IPS={args.allowed_ips}"] if getattr(args, 'allowed_ips', None) else []),
*([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []),
- *(["DISABLE_AUTH=1"] if getattr(args, 'disable_auth', False) else [])
+ *(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_disable_auth', False) else [])
],
"volumes": [
"firegex_data:/execute/db"
@@ -441,14 +441,17 @@ def write_compose(skip_password = True):
}))
def get_password():
- # Authentication is delegated to the reverse proxy in this mode, so never
- # prompt for or persist an unused Firegex password during initial startup.
- if getattr(args, 'disable_auth', False):
- return None
if volume_exists() or args.psw_on_web or (g.standalone_mode and os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db"))):
return None
if args.startup_psw:
return args.startup_psw
+ # Authentication is delegated to the reverse proxy in this mode, so don't block
+ # startup on an interactive prompt for a password nothing will check. An explicit
+ # --startup-psw is still honored above: without a password in the database, turning
+ # authentication back on would leave the instance in the "set the initial password"
+ # state, where anyone able to reach it can claim it.
+ if getattr(args, 'unsafe_disable_auth', False):
+ return None
psw_set = None
while True:
while True:
@@ -470,6 +473,27 @@ def get_password():
def volume_exists():
return "firegex_firegex_data" in cmd_check('docker volume ls --filter "name=^firegex_firegex_data$"', get_output=True)
+def firegex_db_exists():
+ """Whether a database (and therefore a possibly already set password) is around."""
+ if g.standalone_mode:
+ return os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db"))
+ return volume_exists()
+
+def warn_if_auth_disabled():
+ if not getattr(args, 'unsafe_disable_auth', False):
+ return
+ sep()
+ puts("--- AUTHENTICATION DISABLED ---", color=colors.yellow, is_bold=True)
+ puts("Firegex will treat every request that reaches it as a full administrator.", color=colors.red)
+ puts("Only expose it behind a reverse proxy that performs the access control itself.", color=colors.red)
+ puts(f"This setting is stored in {g.configfile} and stays active on the next start:", color=colors.red)
+ puts("pass --no-unsafe-disable-auth to turn the built-in authentication back on.", color=colors.red)
+ if not firegex_db_exists() and not args.startup_psw:
+ puts("No password is set: if you re-enable authentication later, firegex will start in", color=colors.yellow)
+ puts("its initial-setup state, where anyone able to reach it can choose the password.", color=colors.yellow)
+ puts("Set one now with '-P ', or later with 'python3 run.py config --password'.", color=colors.yellow)
+ sep()
+
def nfqueue_exists():
import socket
import fcntl
@@ -887,6 +911,8 @@ def run_standalone():
puts(f"Process PID: {pid}", color=colors.cyan)
return
+ warn_if_auth_disabled()
+
# Set up environment variables
env_vars = [
f"PORT={args.port}",
@@ -898,8 +924,8 @@ def run_standalone():
env_vars.append(f"ALLOWED_IPS={args.allowed_ips}")
if getattr(args, 'proxy_ip_header', None):
env_vars.append(f"PROXY_IP_HEADER={args.proxy_ip_header}")
- if getattr(args, 'disable_auth', False):
- env_vars.append("DISABLE_AUTH=1")
+ if getattr(args, 'unsafe_disable_auth', False):
+ env_vars.append("UNSAFE_DISABLE_AUTH=1")
# Add password if set
psw_set = get_password()
@@ -989,7 +1015,7 @@ def handle_config_command(args):
puts(f"Port: {config['port']}", color=colors.white)
puts(f"Host: {config['host']}", color=colors.white)
puts(f"Socket dir: {config['socket_dir']}", color=colors.white)
- puts(f"Authentication disabled: {config.get('disable_auth', False)}", color=colors.white)
+ puts(f"Authentication disabled: {config.get('unsafe_disable_auth', False)}", color=colors.white)
puts(f"Config file: {g.configfile}", color=colors.white)
return
@@ -1011,6 +1037,15 @@ def handle_config_command(args):
config_changed = True
puts(f"Socket dir set to: {args.socket_dir}", color=colors.green)
+ if getattr(args, 'unsafe_disable_auth', None) is not None:
+ config["unsafe_disable_auth"] = args.unsafe_disable_auth
+ config_changed = True
+ if args.unsafe_disable_auth:
+ puts("Authentication disabled: every request reaching firegex becomes a full administrator", color=colors.red, is_bold=True)
+ else:
+ puts("Authentication enabled", color=colors.green)
+ puts("The change is applied on the next start/restart of firegex.", color=colors.yellow)
+
if hasattr(args, 'password') and args.password is not None:
new_password = args.password
if new_password == "":
@@ -1185,6 +1220,7 @@ def main():
puts("Firegex", color=colors.yellow, end="")
puts(" will start on port ", end="")
puts(f"{args.port}", color=colors.cyan)
+ warn_if_auth_disabled()
write_compose(skip_password=False)
if not g.build:
puts("Downloading docker image from github packages 'docker pull ghcr.io/pwnzer0tt1/firegex'", color=colors.green)
@@ -1198,6 +1234,7 @@ def main():
composecmd(compose_cmd, g.composefile)
case "restart":
if check_already_running():
+ warn_if_auth_disabled()
write_compose()
# A plain `restart` preserves the old container environment, so
# changes to access-control options would not take effect.
diff --git a/tests/ipfilter_test.py b/tests/ipfilter_test.py
index 6f5578c..e876f93 100644
--- a/tests/ipfilter_test.py
+++ b/tests/ipfilter_test.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""
-Verifies the IP-based access control middleware (IPFilterMiddleware in backend/app.py),
-configured via `run.py`'s --allowed-ips / --proxy-ip-header flags (ALLOWED_IPS / PROXY_IP_HEADER
-env vars).
+Verifies the access control sitting in front of the API: the IP-based middleware
+(IPFilterMiddleware in backend/app.py), configured via `run.py`'s --allowed-ips /
+--proxy-ip-header flags (ALLOWED_IPS / PROXY_IP_HEADER env vars), and the --unsafe-disable-auth
+flag (UNSAFE_DISABLE_AUTH) that hands access control over to a reverse proxy.
Unlike the rest of the test suite, this script manages firegex's own start/stop lifecycle,
since ALLOWED_IPS/PROXY_IP_HEADER are only read once at process startup (no live reload) -
@@ -10,13 +11,16 @@ each scenario needs a fresh restart with different flags. Must be run on the sam
host firegex runs on (same requirements as run.py itself).
"""
import argparse
+import importlib.util
import os
import socket
import subprocess
import sys
+import tempfile
import time
import requests
+import socketio
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://127.0.0.1:4444/"
@@ -53,8 +57,8 @@ def wait_ready(timeout=90):
return False
-def restart_with(allowed_ips=None, proxy_ip_header=None, disable_auth=None):
- print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}, disable_auth={disable_auth!r}) ---")
+def restart_with(allowed_ips=None, proxy_ip_header=None, unsafe_disable_auth=None):
+ print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}, unsafe_disable_auth={unsafe_disable_auth!r}) ---")
run_py("stop")
args = ["start", "-P", "testpassword"]
# An empty string still needs to be passed explicitly to clear a previous value.
@@ -62,14 +66,55 @@ def restart_with(allowed_ips=None, proxy_ip_header=None, disable_auth=None):
args += ["--allowed-ips", allowed_ips]
if proxy_ip_header is not None:
args += ["--proxy-ip-header", proxy_ip_header]
- if disable_auth is not None:
- args += ["--disable-auth" if disable_auth else "--no-disable-auth"]
+ if unsafe_disable_auth is not None:
+ args += ["--unsafe-disable-auth" if unsafe_disable_auth else "--no-unsafe-disable-auth"]
run_py(*args)
if not wait_ready():
print("FATAL: firegex didn't come back up after restart")
sys.exit(1)
+def sio_connect_accepted(token=None):
+ """Whether a socket.io client gets past the `connect` auth handler. The raw handshake
+ check below only covers the HTTP upgrade, which happens before that handler runs, so
+ it cannot tell an accepted connection from a rejected one."""
+ client = socketio.Client()
+ try:
+ client.connect(BASE.rstrip("/"), socketio_path="/sock/socket.io",
+ transports=["websocket"],
+ auth={"token": token} if token is not None else None,
+ wait_timeout=10)
+ except socketio.exceptions.ConnectionError:
+ return False
+ finally:
+ try:
+ client.disconnect()
+ except Exception:
+ pass
+ return True
+
+
+def check_startup_password_survives_disabled_auth():
+ """--unsafe-disable-auth must not swallow an explicit -P, otherwise re-enabling authentication
+ later leaves the instance in its initial-setup state, where anyone able to reach it
+ chooses the password.
+
+ get_password() is pure argument handling, so it is checked directly against a throwaway
+ config file: reproducing it against the live instance would mean wiping its database."""
+ spec = importlib.util.spec_from_file_location("firegex_run", os.path.join(REPO_ROOT, "run.py"))
+ run_module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(run_module)
+ globs = run_module.get_password.__globals__
+ globs["volume_exists"] = lambda: False # pretend this is a first start
+ with tempfile.TemporaryDirectory() as tmp:
+ run_module.g.configfile = os.path.join(tmp, "conf.json")
+ for extra in ([], ["--unsafe-disable-auth"]):
+ globs["args"] = run_module.gen_args(["start", "-P", "testpassword", *extra])
+ got = run_module.get_password()
+ check(f"first start with -P {' '.join(extra) or '(no extra flag)'} -> password is kept",
+ got == "testpassword", f"got {got!r}")
+
+
def raw_ws_handshake_rejected(path="/sock/socket.io/?EIO=4&transport=websocket", extra_headers=None, timeout=5):
"""Best-effort check: performs a raw WS upgrade handshake and reports whether the
server completed it (101 Switching Protocols) or refused/closed the connection.
@@ -137,18 +182,60 @@ if __name__ == "__main__":
r = requests.get(BASE + "api/status", headers={"X-Forwarded-For": "not-an-ip"})
check("malformed PROXY_IP_HEADER value -> fails closed (403)", r.status_code == 403, f"got {r.status_code}")
- # 6. Built-in authentication can be disabled for a reverse proxy that owns
- # access control. Protected API routes and the status endpoint then work
- # without a bearer token.
- restart_with(allowed_ips="127.0.0.1/32", disable_auth=True)
+ # 6. Authentication on (the default): the API and socket.io both need a token.
+ restart_with(allowed_ips="", unsafe_disable_auth=False)
r = requests.get(BASE + "api/status")
- check("DISABLE_AUTH reports a logged-in running app", r.status_code == 200 and r.json().get("status") == "run" and r.json().get("loggined") is True, f"got {r.status_code}: {r.text}")
+ check("auth enabled -> not logged in without a token",
+ r.status_code == 200 and r.json().get("loggined") is False and r.json().get("auth_disabled") is False,
+ f"got {r.status_code}: {r.text}")
r = requests.get(BASE + "api/interfaces")
- check("DISABLE_AUTH permits protected API without bearer token", r.status_code == 200, f"got {r.status_code}")
+ check("auth enabled -> protected API without a bearer token is refused", r.status_code == 401, f"got {r.status_code}")
+ check("auth enabled -> socket.io without a token is refused", sio_connect_accepted("") is False)
+
+ # 7. Built-in authentication can be disabled for a reverse proxy that owns
+ # access control. Protected API routes, the status endpoint and socket.io then
+ # work without a bearer token.
+ # No IP allowlist here on purpose: whether the container sees the test client as
+ # 127.0.0.1 depends on how docker publishes the port, and that is scenario 3's
+ # business, not this one's.
+ restart_with(allowed_ips="", unsafe_disable_auth=True)
+ r = requests.get(BASE + "api/status")
+ check("UNSAFE_DISABLE_AUTH reports a logged-in running app",
+ r.status_code == 200 and r.json().get("status") == "run" and r.json().get("loggined") is True,
+ f"got {r.status_code}: {r.text}")
+ check("UNSAFE_DISABLE_AUTH is advertised to the frontend", r.json().get("auth_disabled") is True, f"got {r.text}")
+ r = requests.get(BASE + "api/interfaces")
+ check("UNSAFE_DISABLE_AUTH permits protected API without bearer token", r.status_code == 200, f"got {r.status_code}")
+ check("UNSAFE_DISABLE_AUTH permits socket.io with an empty token", sio_connect_accepted("") is True)
+ check("UNSAFE_DISABLE_AUTH permits socket.io with no auth payload at all", sio_connect_accepted(None) is True)
+
+ # 8. The password endpoints must stay shut while authentication is disabled: an
+ # anonymous caller could otherwise plant a credential that keeps working once
+ # --no-unsafe-disable-auth is passed.
+ r = requests.post(BASE + "api/change-password", json={"password": "plantedbyanyone", "expire": False})
+ check("UNSAFE_DISABLE_AUTH -> unauthenticated change-password is refused", r.status_code == 403, f"got {r.status_code}: {r.text}")
+ r = requests.post(BASE + "api/set-password", json={"password": "plantedbyanyone"})
+ check("UNSAFE_DISABLE_AUTH -> unauthenticated set-password is refused", r.status_code == 403, f"got {r.status_code}: {r.text}")
+ r = requests.post(BASE + "api/login", data={"username": "x", "password": "testpassword"})
+ check("UNSAFE_DISABLE_AUTH -> login is refused (there is no session to hand out)", r.status_code == 403, f"got {r.status_code}: {r.text}")
+
+ # 9. --no-unsafe-disable-auth puts authentication back in charge of the running instance.
+ restart_with(allowed_ips="", unsafe_disable_auth=False)
+ r = requests.get(BASE + "api/interfaces")
+ check("--no-unsafe-disable-auth restores authentication on protected API", r.status_code == 401, f"got {r.status_code}")
+ r = requests.post(BASE + "api/login", data={"username": "x", "password": "testpassword"})
+ check("--no-unsafe-disable-auth -> the real password logs in again", r.status_code == 200, f"got {r.status_code}: {r.text}")
+ check("--no-unsafe-disable-auth -> socket.io accepts the freshly issued token",
+ sio_connect_accepted(r.json().get("access_token", "")) is True)
+ check("plaintext planted while auth was disabled is not a valid password",
+ requests.post(BASE + "api/login", data={"username": "x", "password": "plantedbyanyone"}).status_code == 406)
+
+ # 10. run.py must not drop an explicit -P when authentication is disabled.
+ check_startup_password_survives_disabled_auth()
finally:
if not args.skip_cleanup_restart:
- restart_with(allowed_ips="", disable_auth=False)
+ restart_with(allowed_ips="", unsafe_disable_auth=False)
print()
if failures:
diff --git a/tests/requirements.txt b/tests/requirements.txt
index e55b384..50707e1 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -5,3 +5,4 @@ numpy
cryptography
psutil
pydantic
+python-socketio[client]