From 6fb08f41f65ddf269355c1bb0d86a1919334df16 Mon Sep 17 00:00:00 2001 From: Savva Serebriakov Date: Wed, 2 Sep 2026 19:08:51 +0300 Subject: [PATCH 1/2] CUT-22: add reverse-proxy support to firegex --- README.md | 10 ++++++++++ backend/app.py | 7 +++++-- run.py | 28 +++++++++++++++++++++++----- tests/ipfilter_test.py | 17 ++++++++++++++--- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index aebfe0d..8e650d2 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,16 @@ This is especially useful if you lost the current password: it's applied directl Firegex can also be restricted to accept connections only from a set of trusted CIDR ranges (`--allowed-ips`, optionally combined with `--proxy-ip-header` when running behind a reverse proxy) — see `python3 run.py start -h`. +### Reverse proxy without Firegex authentication + +To delegate access control entirely to a reverse proxy, start Firegex with `--disable-auth`: + +```bash +python3 run.py start --host 127.0.0.1 --disable-auth +``` + +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. + ## Documentation Each module above has its own markdown guide under [`docs/`](docs/), covering how to use it and how it works internally. The same files are rendered directly in the Firegex web interface (via the docs button on each page), so they're always in sync with what you see in the app. diff --git a/backend/app.py b/backend/app.py index b934522..26660f6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -39,6 +39,7 @@ 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"} class IPFilterMiddleware: def __init__(self, app): @@ -116,7 +117,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 "init" if db.get("password") is None else "run" +def APP_STATUS(): return "run" if 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: @@ -150,6 +151,8 @@ async def refresh_frontend(additional:list[str]=[]): await socketio_emit([]+additional) async def check_login(token: str = Depends(oauth2_scheme)): + if DISABLE_AUTH: + return True if not token: return False try: @@ -161,7 +164,7 @@ async def check_login(token: str = Depends(oauth2_scheme)): @utils.socketio.on("connect") async def sio_connect(sid, environ, auth): - if not auth or not await check_login(auth.get("token")): + if not DISABLE_AUTH and (not auth or not await check_login(auth.get("token"))): raise ConnectionRefusedError("Unauthorized") utils.sid_list.add(sid) diff --git a/run.py b/run.py index e66dd19..10e2604 100755 --- a/run.py +++ b/run.py @@ -105,7 +105,8 @@ def load_config(): "host": "any", "socket_dir": None, "allowed_ips": None, - "proxy_ip_header": None + "proxy_ip_header": None, + "disable_auth": False, } if os.path.isfile(g.configfile): @@ -167,6 +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('--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) @@ -183,6 +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('--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) @@ -201,6 +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('--show', required=False, action="store_true", help='Show current configuration', default=False) args = parser.parse_args(args=args_to_parse) @@ -257,6 +261,9 @@ 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 + config_changed = True if config_changed: save_config(config) @@ -360,7 +367,8 @@ def write_compose(skip_password = True): *(["SOCKET_DIR=/run/firegex"] if args.socket_dir else []), *([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 []) + *([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 []) ], "volumes": [ "firegex_data:/execute/db", @@ -416,7 +424,8 @@ def write_compose(skip_password = True): *([f"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []), *([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 []) + *([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 []) ], "volumes": [ "firegex_data:/execute/db" @@ -432,6 +441,10 @@ 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: @@ -885,6 +898,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") # Add password if set psw_set = get_password() @@ -974,6 +989,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"Config file: {g.configfile}", color=colors.white) return @@ -1183,8 +1199,10 @@ def main(): case "restart": if check_already_running(): write_compose() - puts("Running 'docker compose restart'\n", color=colors.green) - composecmd("restart", g.composefile) + # A plain `restart` preserves the old container environment, so + # changes to access-control options would not take effect. + puts("Running 'docker compose up -d --force-recreate'\n", color=colors.green) + composecmd("up -d --force-recreate", g.composefile) else: puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True) case "stop": diff --git a/tests/ipfilter_test.py b/tests/ipfilter_test.py index 53896b0..6f5578c 100644 --- a/tests/ipfilter_test.py +++ b/tests/ipfilter_test.py @@ -53,8 +53,8 @@ def wait_ready(timeout=90): return False -def restart_with(allowed_ips=None, proxy_ip_header=None): - print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}) ---") +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}) ---") run_py("stop") args = ["start", "-P", "testpassword"] # An empty string still needs to be passed explicitly to clear a previous value. @@ -62,6 +62,8 @@ def restart_with(allowed_ips=None, proxy_ip_header=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"] run_py(*args) if not wait_ready(): print("FATAL: firegex didn't come back up after restart") @@ -135,9 +137,18 @@ 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) + 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}") + r = requests.get(BASE + "api/interfaces") + check("DISABLE_AUTH permits protected API without bearer token", r.status_code == 200, f"got {r.status_code}") + finally: if not args.skip_cleanup_restart: - restart_with(allowed_ips="") + restart_with(allowed_ips="", disable_auth=False) print() if failures: From 2c3316b0366b7c27ab3e289affa82835a65f30ba Mon Sep 17 00:00:00 2001 From: Domingo Dirutigliano Date: Thu, 3 Sep 2026 01:07:51 +0200 Subject: [PATCH 2/2] 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]