mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 14:31:00 +00:00
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.
This commit is contained in:
parent
6fb08f41f6
commit
2c3316b036
13
README.md
13
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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ class StatusModel(BaseModel):
|
||||
status: str
|
||||
loggined: bool
|
||||
version: str
|
||||
auth_disabled: bool = False
|
||||
|
||||
class PasswordForm(BaseModel):
|
||||
password: str
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
<Box className="flex-spacer" />
|
||||
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Label>Firewall Access</Menu.Label>
|
||||
<Menu.Item leftSection={<FaLock size={14} />} onClick={() => setChangePasswordModal(true)}>Change Password</Menu.Item>
|
||||
<Divider />
|
||||
{!authDisabled && <>
|
||||
<Menu.Label>Firewall Access</Menu.Label>
|
||||
<Menu.Item leftSection={<FaLock size={14} />} onClick={() => setChangePasswordModal(true)}>Change Password</Menu.Item>
|
||||
<Divider />
|
||||
</>}
|
||||
<Menu.Label>Actions</Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<MdOutlineSettingsBackupRestore size={18} />} onClick={() => setResetFiregexModal(true)}>Reset Firegex</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
@ -70,10 +72,10 @@ function HeaderPage(props: any) {
|
||||
<AiFillHome size="25px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Logout" position='bottom' color="blue">
|
||||
{!authDisabled && <Tooltip label="Logout" position='bottom' color="blue">
|
||||
<ActionIcon color="blue" onClick={logout_action} size="xl" radius="md" variant="filled">
|
||||
<ImExit size={23} style={{marginTop:"3px", marginLeft:"2px"}}/></ActionIcon>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
<ResetPasswordModal opened={changePasswordModal} onClose={() => setChangePasswordModal(false)} />
|
||||
<ResetModal opened={resetFiregexModal} onClose={() => setResetFiregexModal(false)} />
|
||||
<Space w="xl" />
|
||||
|
||||
@ -16,7 +16,8 @@ export type LoginResponse = {
|
||||
export type ServerStatusResponse = {
|
||||
status:string,
|
||||
loggined:boolean,
|
||||
version?:string
|
||||
version?:string,
|
||||
auth_disabled?:boolean
|
||||
}
|
||||
|
||||
export type PasswordSend = {
|
||||
|
||||
@ -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<SystemState>()((set) => ({
|
||||
version: null,
|
||||
setVersion: (version) => set({ version }),
|
||||
authDisabled: false,
|
||||
setAuthDisabled: (authDisabled) => set({ authDisabled }),
|
||||
}));
|
||||
|
||||
71
run.py
71
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 <password>', 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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -5,3 +5,4 @@ numpy
|
||||
cryptography
|
||||
psutil
|
||||
pydantic
|
||||
python-socketio[client]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user