From 2c3316b0366b7c27ab3e289affa82835a65f30ba Mon Sep 17 00:00:00 2001 From: Domingo Dirutigliano Date: Thu, 3 Sep 2026 01:07:51 +0200 Subject: [PATCH] Harden the no-auth reverse-proxy mode Rename the flag to --unsafe-disable-auth so the risk is visible at the call site, and close the ways the mode could outlive itself: - Keep an explicit -P/--startup-psw instead of dropping it. Without a password in the database, --no-unsafe-disable-auth brought firegex up in its initial-setup state, where anyone able to reach it could choose the password. - Refuse /api/login, /api/set-password and /api/change-password while authentication is disabled: an anonymous caller could otherwise plant a credential that kept working once authentication was turned back on. run.py config --password still works, writing to the database from the host. - Print a banner on every start/restart with authentication disabled. The flag is persisted in the config file, so a later bare `run.py start` inherits it. - Report the change in `run.py config --unsafe-disable-auth`, which claimed "No configuration changes specified" while persisting it, and stop that command from nulling the stored port and host on the way. - Expose the state as auth_disabled in /api/status and hide the logout button and the change-password entry, which were silent no-ops in this mode. Tests cover both directions of the toggle, the socket.io handler, the password endpoints and the -P handling. --- README.md | 13 ++- backend/app.py | 22 ++++- backend/utils/models.py | 1 + frontend/src/App.tsx | 3 +- frontend/src/components/Header/index.tsx | 14 +-- frontend/src/js/models.ts | 3 +- frontend/src/js/store.ts | 4 + run.py | 71 ++++++++++---- tests/ipfilter_test.py | 115 ++++++++++++++++++++--- tests/requirements.txt | 1 + 10 files changed, 200 insertions(+), 47 deletions(-) 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]