mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
Merge pull request #40 from devopn/main
CUT-22: add reverse-proxy support to firegex with no-auth
This commit is contained in:
commit
c28d4488de
17
README.md
17
README.md
@ -90,6 +90,23 @@ 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 `--unsafe-disable-auth`:
|
||||
|
||||
```bash
|
||||
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: **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
|
||||
|
||||
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.
|
||||
|
||||
@ -39,6 +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", "")
|
||||
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):
|
||||
@ -116,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 "init" if db.get("password") is None else "run"
|
||||
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:
|
||||
@ -150,6 +156,8 @@ async def refresh_frontend(additional:list[str]=[]):
|
||||
await socketio_emit([]+additional)
|
||||
|
||||
async def check_login(token: str = Depends(oauth2_scheme)):
|
||||
if UNSAFE_DISABLE_AUTH:
|
||||
return True
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
@ -161,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 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)
|
||||
|
||||
@ -198,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 == "":
|
||||
@ -217,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 == "":
|
||||
@ -228,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>
|
||||
{!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 }),
|
||||
}));
|
||||
|
||||
69
run.py
69
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,
|
||||
"unsafe_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('--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)
|
||||
@ -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('--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)
|
||||
|
||||
@ -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('--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)
|
||||
|
||||
@ -242,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"]:
|
||||
@ -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 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:
|
||||
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 []),
|
||||
*(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_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 []),
|
||||
*(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_disable_auth', False) else [])
|
||||
],
|
||||
"volumes": [
|
||||
"firegex_data:/execute/db"
|
||||
@ -436,6 +445,13 @@ def get_password():
|
||||
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:
|
||||
@ -457,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
|
||||
@ -874,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}",
|
||||
@ -885,6 +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, 'unsafe_disable_auth', False):
|
||||
env_vars.append("UNSAFE_DISABLE_AUTH=1")
|
||||
|
||||
# Add password if set
|
||||
psw_set = get_password()
|
||||
@ -974,6 +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('unsafe_disable_auth', False)}", color=colors.white)
|
||||
puts(f"Config file: {g.configfile}", color=colors.white)
|
||||
return
|
||||
|
||||
@ -995,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 == "":
|
||||
@ -1169,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)
|
||||
@ -1182,9 +1234,12 @@ def main():
|
||||
composecmd(compose_cmd, g.composefile)
|
||||
case "restart":
|
||||
if check_already_running():
|
||||
warn_if_auth_disabled()
|
||||
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":
|
||||
|
||||
@ -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):
|
||||
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, 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,12 +66,55 @@ 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 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.
|
||||
@ -135,9 +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. 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("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("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="")
|
||||
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