Merge pull request #37 from Pwnzer0tt1/dev

enforced security over import backup, minor changes
This commit is contained in:
Domingo Dirutigliano 2026-07-23 18:45:26 +02:00 committed by GitHub
commit 6cc2b7d6bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 69 additions and 19 deletions

View File

@ -9,7 +9,7 @@ from fastapi import FastAPI, HTTPException, Depends, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt from jose import jwt
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD, safe_join
from utils.loader import frontend_deploy, load_routers from utils.loader import frontend_deploy, load_routers
from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -17,6 +17,8 @@ from fastapi.middleware.cors import CORSMiddleware
import socketio import socketio
from socketio.exceptions import ConnectionRefusedError from socketio.exceptions import ConnectionRefusedError
import hashlib import hashlib
import hmac
import re
from ipaddress import ip_network, ip_address from ipaddress import ip_network, ip_address
# DB init # DB init
db = SQLite('db/firegex.db') db = SQLite('db/firegex.db')
@ -125,9 +127,13 @@ async def hash_psw(psw: str) -> str:
return await asyncio.to_thread(_hash_psw_sync, psw) return await asyncio.to_thread(_hash_psw_sync, psw)
def _verify_psw_sync(psw: str, hashed: str) -> bool: def _verify_psw_sync(psw: str, hashed: str) -> bool:
psw_hash, salt = hashed.split("-") try:
psw_hash, salt = hashed.split("-")
except (ValueError, AttributeError):
return False
new_hashed = hashlib.pbkdf2_hmac("sha256", psw.encode(), salt.encode(), 500_000).hex() new_hashed = hashlib.pbkdf2_hmac("sha256", psw.encode(), salt.encode(), 500_000).hex()
return new_hashed == psw_hash # Constant-time comparison to avoid leaking hash-match progress via timing.
return hmac.compare_digest(new_hashed, psw_hash)
async def verify_psw(psw: str, hashed: str) -> bool: async def verify_psw(psw: str, hashed: str) -> bool:
return await asyncio.to_thread(_verify_psw_sync, psw, hashed) return await asyncio.to_thread(_verify_psw_sync, psw, hashed)
@ -297,9 +303,45 @@ async def export_db():
dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8') dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8')
return dbs return dbs
# Backup entries are limited to plain basenames of the two shapes export_db()
# produces: "<name>.db" databases and "<id>.py" nfproxy filter files. The charset
# forbids path separators and "..", so a crafted key can't escape the target dir.
_SAFE_DB_NAME = re.compile(r'^[A-Za-z0-9_-]+\.db$')
_SAFE_PY_NAME = re.compile(r'^[A-Za-z0-9_-]+\.py$')
@api.post('/import', response_model=StatusMessageModel) @api.post('/import', response_model=StatusMessageModel)
async def import_db(data: dict): async def import_db(data: dict):
"""Import all configuration databases from JSON""" """Import all configuration databases from JSON"""
# Validate the ENTIRE payload before touching any file, so a malformed or
# malicious backup is rejected atomically instead of being half-applied.
db_imports = [] # (destination path, dump dict)
filter_imports = [] # (destination path, decoded bytes)
for key, value in data.items():
if not isinstance(key, str):
raise HTTPException(status_code=400, detail="Invalid backup: keys must be strings")
if _SAFE_DB_NAME.match(key):
if not isinstance(value, dict):
raise HTTPException(status_code=400, detail=f"Invalid backup: '{key}' must be an object")
# safe_join is defense-in-depth on top of the regex: it rejects any
# path that would resolve outside the db/ directory.
db_imports.append((safe_join('db', key), value))
elif key == 'nfproxy_filters':
if not isinstance(value, dict):
raise HTTPException(status_code=400, detail="Invalid backup: 'nfproxy_filters' must be an object")
for fname, script_content in value.items():
if not isinstance(fname, str) or not _SAFE_PY_NAME.match(fname):
raise HTTPException(status_code=400, detail=f"Invalid backup: illegal filter filename '{fname}'")
if not isinstance(script_content, str):
raise HTTPException(status_code=400, detail=f"Invalid backup: filter '{fname}' must be a base64 string")
try:
decoded = base64.b64decode(script_content, validate=True)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid backup: filter '{fname}' is not valid base64")
filter_imports.append((safe_join('db/nfproxy_filters', fname), decoded))
else:
raise HTTPException(status_code=400, detail=f"Invalid backup: unexpected entry '{key}'")
if not os.path.exists('db'): if not os.path.exists('db'):
os.makedirs('db') os.makedirs('db')
@ -308,19 +350,17 @@ async def import_db(data: dict):
current_password = db.get("password") current_password = db.get("password")
current_secret = db.get("secret") current_secret = db.get("secret")
for db_file, db_data in data.items(): for db_path, db_data in db_imports:
if db_file.endswith('.db'): temp_db = SQLite(str(db_path))
temp_db = SQLite(os.path.join('db', db_file)) # Load into the existing schema; SQLite.load only writes to tables/columns
# Just load the data, we assume schemas exist or will be matched # that actually exist, so unknown fields in the backup are ignored.
# We don't delete the whole file to preserve DB_VERSION and schemas temp_db.load(db_data)
temp_db.load(db_data)
elif db_file == 'nfproxy_filters': if filter_imports:
if not os.path.exists('db/nfproxy_filters'): os.makedirs('db/nfproxy_filters', exist_ok=True)
os.makedirs('db/nfproxy_filters') for filter_path, decoded in filter_imports:
for f, script_content in db_data.items(): with open(filter_path, 'wb') as script_file:
if f.endswith('.py'): script_file.write(decoded)
with open(os.path.join('db/nfproxy_filters', f), 'wb') as script_file:
script_file.write(base64.b64decode(script_content))
if current_password is not None: if current_password is not None:
db.put("password", current_password) db.put("password", current_password)

View File

@ -144,18 +144,28 @@ class SQLite():
try: try:
cur.execute("BEGIN") cur.execute("BEGIN")
for table_name, rows in data.items(): for table_name, rows in data.items():
# Verify table exists to prevent SQL injection or schema mismatches # Verify the table exists to prevent SQL injection or schema mismatches
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table_name,)) cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table_name,))
if len(cur.fetchall()) == 0: if len(cur.fetchall()) == 0:
continue continue
# Ignore anything that isn't a proper list of row objects
if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
continue
cur.execute(f"DELETE FROM {table_name};") cur.execute(f"DELETE FROM {table_name};")
if rows: if rows:
cols = list(rows[0].keys()) # Column names are identifiers and can't be parameterized, so only
# keep the ones that actually exist on the table - this stops a
# crafted backup from injecting arbitrary identifiers into the query.
cur.execute(f'PRAGMA table_info("{table_name}");')
valid_cols = {r["name"] for r in cur.fetchall()}
cols = [c for c in rows[0].keys() if c in valid_cols]
if not cols:
continue
placeholders = ", ".join(["?"] * len(cols)) placeholders = ", ".join(["?"] * len(cols))
col_names = ", ".join(cols) col_names = ", ".join(cols)
query = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})" query = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})"
for row in rows: for row in rows:
cur.execute(query, [row[c] for c in cols]) cur.execute(query, [row.get(c) for c in cols])
cur.execute("COMMIT") cur.execute("COMMIT")
except Exception as e: except Exception as e:
cur.execute("ROLLBACK") cur.execute("ROLLBACK")