From 4125eff93f6e198e2de9a4867c4782c7811e25fa Mon Sep 17 00:00:00 2001 From: Domingo Dirutigliano Date: Wed, 22 Jul 2026 07:54:38 +0200 Subject: [PATCH] minor adjustments + network address based access control --- backend/app.py | 68 +++++++++++++++++++++--- backend/binsrc/classes/netfilter.cpp | 18 ++++++- backend/binsrc/classes/nfqueue.cpp | 16 ++++-- backend/routers/nfproxy.py | 22 +++++--- frontend/src/App.tsx | 4 +- frontend/src/components/Header/index.tsx | 5 +- frontend/src/components/MainLayout.tsx | 5 +- frontend/src/js/models.ts | 3 +- frontend/src/js/store.ts | 10 ++++ run.py | 29 ++++++++-- 10 files changed, 151 insertions(+), 29 deletions(-) diff --git a/backend/app.py b/backend/app.py index 624fd00..c764a78 100644 --- a/backend/app.py +++ b/backend/app.py @@ -16,7 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware import socketio from socketio.exceptions import ConnectionRefusedError import hashlib - +from ipaddress import ip_network, ip_address # DB init db = SQLite('db/firegex.db') sysctl = SysctlManager({ @@ -34,6 +34,51 @@ async def lifespan(app): yield await shutdown_main() +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", "") + +class IPFilterMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] in ["http", "websocket"]: + if ALLOWED_NETWORKS: + client_ip = None + if PROXY_IP_HEADER: + headers = dict(scope.get("headers", [])) + header_val = headers.get(PROXY_IP_HEADER.lower().encode()) + if header_val: + client_ip = header_val.decode().split(",")[0].strip() + + if not client_ip and scope.get("client"): + client_ip = scope["client"][0] + + if client_ip: + try: + ip_obj = ip_address(client_ip) + if not any(ip_obj in net for net in ALLOWED_NETWORKS): + if scope["type"] == "http": + await send({ + "type": "http.response.start", + "status": 403, + "headers": [(b"content-type", b"text/plain")], + }) + await send({ + "type": "http.response.body", + "body": b"Forbidden", + }) + elif scope["type"] == "websocket": + await send({ + "type": "websocket.close", + "code": 1008 + }) + return + except ValueError: + pass + + await self.app(scope, receive, send) + app = FastAPI( debug=DEBUG, redoc_url=None, @@ -42,6 +87,7 @@ app = FastAPI( title="Firegex API", version=API_VERSION, ) +app.add_middleware(IPFilterMiddleware) if DEBUG: app.add_middleware( @@ -64,17 +110,23 @@ app.mount("/sock", sio_app) def APP_STATUS(): return "init" if db.get("password") is None else "run" def JWT_SECRET(): return db.get("secret") -def hash_psw(psw: str): +def _hash_psw_sync(psw: str) -> str: salt = secrets.token_hex(32) return hashlib.pbkdf2_hmac("sha256", psw.encode(), salt.encode(), 500_000).hex()+"-"+salt -def verify_psw(psw: str, hashed: str) -> bool: +async def hash_psw(psw: str) -> str: + return await asyncio.to_thread(_hash_psw_sync, psw) + +def _verify_psw_sync(psw: str, hashed: str) -> bool: psw_hash, salt = hashed.split("-") new_hashed = hashlib.pbkdf2_hmac("sha256", psw.encode(), salt.encode(), 500_000).hex() return new_hashed == psw_hash -def set_psw(psw: str): - db.put("password", hash_psw(psw)) +async def verify_psw(psw: str, hashed: str) -> bool: + return await asyncio.to_thread(_verify_psw_sync, psw, hashed) + +async def set_psw(psw: str): + db.put("password", await hash_psw(psw)) def create_access_token(data: dict): to_encode = data.copy() @@ -144,7 +196,7 @@ async def login_api(form: OAuth2PasswordRequestForm = Depends()): if form.password == "": return {"status":"Cannot insert an empty password!"} await asyncio.sleep(0.3) # No bruteforce :) - if verify_psw(form.password, db.get("password")): + if await verify_psw(form.password, db.get("password")): return {"access_token": create_access_token({"logged_in": True}), "token_type": "bearer"} raise HTTPException(406,"Wrong password!") @@ -156,7 +208,7 @@ async def set_password(form: PasswordForm): raise HTTPException(status_code=400) if form.password == "": return {"status":"Cannot insert an empty password!"} - set_psw(form.password) + await set_psw(form.password) await refresh_frontend() return {"status":"ok", "access_token": create_access_token({"logged_in": True})} @@ -172,7 +224,7 @@ async def change_password(form: PasswordChangeForm): db.put("secret", secrets.token_hex(32)) await disconnect_all() - set_psw(form.password) + await set_psw(form.password) await refresh_frontend() return {"status":"ok", "access_token": create_access_token({"logged_in": True})} diff --git a/backend/binsrc/classes/netfilter.cpp b/backend/binsrc/classes/netfilter.cpp index 5705c5a..3b7b278 100644 --- a/backend/binsrc/classes/netfilter.cpp +++ b/backend/binsrc/classes/netfilter.cpp @@ -27,7 +27,23 @@ public: PktRequest* pkt; for(;;) { queue.take(pkt); - static_cast(this)->handle_next_packet(pkt); + try { + static_cast(this)->handle_next_packet(pkt); + } catch (const std::exception& e) { + std::cerr << "[error] Exception handling packet: " << e.what() << std::endl; + if (pkt->get_action() == FilterAction::NOACTION) { + try { + pkt->accept(); + } catch (...) {} + } + } catch (...) { + std::cerr << "[error] Unknown exception handling packet" << std::endl; + if (pkt->get_action() == FilterAction::NOACTION) { + try { + pkt->accept(); + } catch (...) {} + } + } delete pkt; } } diff --git a/backend/binsrc/classes/nfqueue.cpp b/backend/binsrc/classes/nfqueue.cpp index 7303f52..c2239ad 100644 --- a/backend/binsrc/classes/nfqueue.cpp +++ b/backend/binsrc/classes/nfqueue.cpp @@ -530,13 +530,21 @@ class NfQueue { return MNL_CB_ERROR; } + struct nfqnl_msg_packet_hdr *ph = (nfqnl_msg_packet_hdr*) mnl_attr_get_payload(attr[NFQA_PACKET_HDR]); + struct nfgenmsg *nfg = (nfgenmsg *)mnl_nlmsg_get_payload(nlh); + + if (attr[NFQA_PAYLOAD] == nullptr) { + cerr << "[error] [NfQueue._real_queue_cb] payload not set" << endl; + char buf[MNL_SOCKET_BUFFER_SIZE]; + struct nlmsghdr *nlh_verdict = nfq_nlmsg_put(buf, NFQNL_MSG_VERDICT, ntohs(nfg->res_id)); + nfq_nlmsg_verdict_put(nlh_verdict, ntohl(ph->packet_id), NF_ACCEPT); + mnl_socket_sendto(info->nl, nlh_verdict, nlh_verdict->nlmsg_len); + return MNL_CB_OK; + } + //Get Payload uint16_t plen = mnl_attr_get_payload_len(attr[NFQA_PAYLOAD]); char *payload = (char *)mnl_attr_get_payload(attr[NFQA_PAYLOAD]); - - //Return result to the kernel - struct nfqnl_msg_packet_hdr *ph = (nfqnl_msg_packet_hdr*) mnl_attr_get_payload(attr[NFQA_PACKET_HDR]); - struct nfgenmsg *nfg = (nfgenmsg *)mnl_nlmsg_get_payload(nlh); bool is_input = ntohl(mnl_attr_get_u32(attr[NFQA_MARK])) & 0x1; // == 0x1337 that is odd handle_func(new PktRequest( diff --git a/backend/routers/nfproxy.py b/backend/routers/nfproxy.py index 27335b3..4cd1076 100644 --- a/backend/routers/nfproxy.py +++ b/backend/routers/nfproxy.py @@ -3,7 +3,7 @@ import sqlite3 from fastapi import APIRouter, HTTPException from pydantic import BaseModel from modules.nfproxy.nftables import FiregexTables -from modules.nfproxy.firewall import STATUS, FirewallManager +from modules.nfproxy.firewall import STATUS, FirewallManager, ServiceNotFoundException from utils.sqlite import SQLite from utils import ip_parse, refactor_name, socketio_emit, PortType from utils.models import ResetRequest, StatusMessageModel @@ -373,9 +373,13 @@ async def join_outstream(sid, data): """Client joins a room.""" srv = data.get("service") if srv: - room = f"nfproxy-outstream-{srv}" - await utils.socketio.enter_room(sid, room) - await utils.socketio.emit(room, firewall.get(srv).read_outstrem_buffer(), room=sid) + try: + srv_manager = firewall.get(srv) + room = f"nfproxy-outstream-{srv}" + await utils.socketio.enter_room(sid, room) + await utils.socketio.emit(room, srv_manager.read_outstrem_buffer(), room=sid) + except ServiceNotFoundException: + pass async def leave_outstream(sid, data): """Client leaves a room.""" @@ -387,9 +391,13 @@ async def join_exception(sid, data): """Client joins a room.""" srv = data.get("service") if srv: - room = f"nfproxy-exception-{srv}" - await utils.socketio.enter_room(sid, room) - await utils.socketio.emit(room, firewall.get(srv).last_exception_time, room=sid) + try: + srv_manager = firewall.get(srv) + room = f"nfproxy-exception-{srv}" + await utils.socketio.enter_room(sid, room) + await utils.socketio.emit(room, srv_manager.last_exception_time, room=sid) + except ServiceNotFoundException: + pass async def leave_exception(sid, data): """Client leaves a room.""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49ba445..01ad226 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import { Firewall } from './pages/Firewall'; import { useQueryClient } from '@tanstack/react-query'; import NFProxy from './pages/NFProxy'; import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails'; -import { useAuthStore } from './js/store'; +import { useAuthStore, useSystemStore } from './js/store'; function App() { @@ -24,6 +24,7 @@ function App() { const [loadinBtn, setLoadingBtn] = useState(false); const queryClient = useQueryClient() const { access_token } = useAuthStore() + const { setVersion } = useSystemStore() useEffect(()=>{ socketio.auth = { token: access_token || "" } @@ -48,6 +49,7 @@ function App() { const getStatus = () =>{ getstatus().then( res =>{ setSystemStatus(res) + setVersion(res.version || "unknown") setReqError(undefined) }).catch(err=>{ setReqError(err.toString()) diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 105845e..b217d5c 100644 --- a/frontend/src/components/Header/index.tsx +++ b/frontend/src/components/Header/index.tsx @@ -9,13 +9,14 @@ import { ImExit } from 'react-icons/im'; import ResetPasswordModal from './ResetPasswordModal'; import ResetModal from './ResetModal'; import { MenuDropDownWithButton } from '../MainLayout'; -import { useNavbarStore } from '../../js/store'; +import { useNavbarStore, useSystemStore } from '../../js/store'; function HeaderPage(props: any) { const navigator = useNavigate() const { navOpened, toggleNav } = useNavbarStore() + const { version } = useSystemStore() const logout_action = () => { logout().then(r => { @@ -48,7 +49,7 @@ function HeaderPage(props: any) { [Fi]*regex -

By Pwnzer0tt1

+

By Pwnzer0tt1 {version && {version}}

diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx index 12b4811..dd592d8 100644 --- a/frontend/src/components/MainLayout.tsx +++ b/frontend/src/components/MainLayout.tsx @@ -5,16 +5,17 @@ import NavBar from './NavBar'; import HeaderPage from './Header'; import { getMainPath } from '../js/utils'; import { useLocation } from 'react-router'; -import { useNavbarStore } from '../js/store'; +import { useNavbarStore, useSessionStore } from '../js/store'; import { HiMenu } from "react-icons/hi"; function MainLayout({ children }:{ children:any }) { const { navOpened } = useNavbarStore() + const { setHomeSection } = useSessionStore() const location = useLocation() useEffect(()=>{ if (location.pathname !== "/"){ - sessionStorage.setItem('home_section', getMainPath()) + setHomeSection(getMainPath()) } },[location.pathname]) return { getHomeSection, }; }; + +interface SystemState { + version: string | null; + setVersion: (version: string | null) => void; +} + +export const useSystemStore = create()((set) => ({ + version: null, + setVersion: (version) => set({ version }), +})); diff --git a/run.py b/run.py index 6b9398b..bfa50ef 100755 --- a/run.py +++ b/run.py @@ -103,7 +103,9 @@ def load_config(): "port": 4444, # any allow to bind service also on ipv6 (see the main of backend to understand why) "host": "any", - "socket_dir": None + "socket_dir": None, + "allowed_ips": None, + "proxy_ip_header": None } if os.path.isfile(g.configfile): @@ -156,6 +158,8 @@ def gen_args(args_to_parse: list[str]|None = None): parser_start.add_argument('--port', "-p", type=int, required=False, help=f'Port where open the web service of the firewall (default from config: {config["port"]})', default=config["port"]) parser_start.add_argument('--host', required=False, help=f'Host IP address to bind the service to (default from config: {config["host"]})', default=config["host"]) 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('--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) @@ -170,6 +174,8 @@ def gen_args(args_to_parse: list[str]|None = None): parser_restart.add_argument('--port', "-p", type=int, required=False, help=f'Port where open the web service of the firewall (default from config: {config["port"]})', default=config["port"]) parser_restart.add_argument('--host', required=False, help=f'Host IP address to bind the service to (default from config: {config["host"]})', default=config["host"]) 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('--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) @@ -185,6 +191,8 @@ def gen_args(args_to_parse: list[str]|None = None): parser_config.add_argument('--port', "-p", type=int, required=False, help='Set default port for web service') parser_config.add_argument('--host', required=False, help='Set default host IP address to bind the service to') parser_config.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_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('--show', required=False, action="store_true", help='Show current configuration', default=False) args = parser.parse_args(args=args_to_parse) @@ -234,6 +242,12 @@ def gen_args(args_to_parse: list[str]|None = None): if hasattr(args, 'socket_dir') and args.socket_dir != config["socket_dir"]: config["socket_dir"] = args.socket_dir config_changed = True + if hasattr(args, 'allowed_ips') and args.allowed_ips != config.get("allowed_ips"): + config["allowed_ips"] = args.allowed_ips + config_changed = True + 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 config_changed: save_config(config) @@ -335,7 +349,9 @@ def write_compose(skip_password = True): f"NTHREADS={args.threads}", *([f"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []), *(["SOCKET_DIR=/run/firegex"] if args.socket_dir else []), - *([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() 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 []) ], "volumes": [ "firegex_data:/execute/db", @@ -389,7 +405,9 @@ def write_compose(skip_password = True): f"PORT={args.port}", f"NTHREADS={args.threads}", *([f"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []), - *([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() 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 []) ], "volumes": [ "firegex_data:/execute/db" @@ -854,6 +872,11 @@ def run_standalone(): f"NTHREADS={args.threads}", ] + if getattr(args, 'allowed_ips', None): + 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}") + # Add password if set psw_set = get_password() if psw_set: