diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8ac7cfff..a7640f26 100755 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -137,7 +137,7 @@ ENV LANG=C.UTF-8 RUN apk add --no-cache bash mtr libbsd zip lsblk tzdata curl arp-scan iproute2 iproute2-ss nmap fping \ nmap-scripts traceroute nbtscan net-tools net-snmp-tools bind-tools awake ca-certificates \ sqlite php83 php83-fpm php83-cgi php83-curl php83-sqlite3 php83-session python3 envsubst \ - nginx supercronic shadow su-exec && \ + nginx supercronic shadow su-exec jq && \ rm -Rf /var/cache/apk/* && \ rm -Rf /etc/nginx && \ addgroup -g ${NETALERTX_GID} ${NETALERTX_GROUP} && \ diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index f766bd0e..2a9df319 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -32,7 +32,6 @@ LOG_FILES=( LOG_DB_IS_LOCKED LOG_NGINX_ERROR ) - sudo chmod 666 /var/run/docker.sock 2>/dev/null || true sudo chown "$(id -u)":"$(id -g)" /workspaces sudo chmod 755 /workspaces @@ -55,6 +54,9 @@ sudo install -d -m 777 /tmp/log/plugins sudo rm -rf /entrypoint.d sudo ln -s "${SOURCE_DIR}/install/production-filesystem/entrypoint.d" /entrypoint.d +sudo rm -rf /services +sudo ln -s "${SOURCE_DIR}/install/production-filesystem/services" /services + sudo rm -rf "${NETALERTX_APP}" sudo ln -s "${SOURCE_DIR}/" "${NETALERTX_APP}" @@ -88,8 +90,6 @@ sudo chmod 777 "${LOG_DB_IS_LOCKED}" sudo pkill -f python3 2>/dev/null || true -sudo chmod -R 777 "${PY_SITE_PACKAGES}" "${NETALERTX_DATA}" 2>/dev/null || true - sudo chown -R "${NETALERTX_USER}:${NETALERTX_GROUP}" "${NETALERTX_APP}" date +%s | sudo tee "${NETALERTX_FRONT}/buildtimestamp.txt" >/dev/null diff --git a/Dockerfile b/Dockerfile index 6d308642..0836f7fd 100755 --- a/Dockerfile +++ b/Dockerfile @@ -134,7 +134,7 @@ ENV LANG=C.UTF-8 RUN apk add --no-cache bash mtr libbsd zip lsblk tzdata curl arp-scan iproute2 iproute2-ss nmap fping \ nmap-scripts traceroute nbtscan net-tools net-snmp-tools bind-tools awake ca-certificates \ sqlite php83 php83-fpm php83-cgi php83-curl php83-sqlite3 php83-session python3 envsubst \ - nginx supercronic shadow su-exec && \ + nginx supercronic shadow su-exec jq && \ rm -Rf /var/cache/apk/* && \ rm -Rf /etc/nginx && \ addgroup -g ${NETALERTX_GID} ${NETALERTX_GROUP} && \ diff --git a/Dockerfile.debian b/Dockerfile.debian index 958390ba..e3b196d5 100755 --- a/Dockerfile.debian +++ b/Dockerfile.debian @@ -134,6 +134,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ mtr \ procps \ gosu \ + jq \ && wget -qO /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \ && echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list \ && apt-get update \ diff --git a/back/app.conf b/back/app.conf index d0281eaa..9c1bbfe9 100755 --- a/back/app.conf +++ b/back/app.conf @@ -16,7 +16,7 @@ # # Scan multiple interfaces (eth1 and eth0): # SCAN_SUBNETS = [ '192.168.1.0/24 --interface=eth1', '192.168.1.0/24 --interface=eth0' ] - +BACKEND_API_URL='/server' DISCOVER_PLUGINS=True SCAN_SUBNETS=['--localnet'] TIMEZONE='Europe/Berlin' @@ -100,6 +100,8 @@ MQTT_PASSWORD='passw0rd' MQTT_QOS=0 MQTT_DELAY_SEC=2 +GRAPHQL_PORT=20212 + #-------------------IMPORTANT INFO-------------------# # This file is ingested by a python script, so if # diff --git a/docs/API.md b/docs/API.md index 27da9982..bca932cf 100755 --- a/docs/API.md +++ b/docs/API.md @@ -78,6 +78,7 @@ http://:/ * [Sync](API_SYNC.md) – Synchronization between multiple NetAlertX instances * [Logs](API_LOGS.md) – Purging of logs and adding to the event execution queue for user triggered events * [DB query](API_DBQUERY.md) (⚠ Internal) - Low level database access - use other endpoints if possible +* `/server` (⚠ Internal) - Backend server endpoint for internal communication only - **do not use directly** ### MCP Server Bridge diff --git a/front/js/api.js b/front/js/api.js index 6f927913..32129ab0 100644 --- a/front/js/api.js +++ b/front/js/api.js @@ -4,11 +4,8 @@ function getApiBase() if(apiBase == "") { - const protocol = window.location.protocol.replace(':', ''); - const host = window.location.hostname; - const port = getSetting("GRAPHQL_PORT"); - - apiBase = `${protocol}://${host}:${port}`; + // Default to the same-origin proxy bridge + apiBase = "/server"; } // Remove trailing slash for consistency diff --git a/install/production-filesystem/services/config/nginx/netalertx.conf.template b/install/production-filesystem/services/config/nginx/netalertx.conf.template index 6a567056..0dfbeb18 100755 --- a/install/production-filesystem/services/config/nginx/netalertx.conf.template +++ b/install/production-filesystem/services/config/nginx/netalertx.conf.template @@ -94,6 +94,19 @@ http { access_log /tmp/log/nginx-access.log main; + # Map 1: The Legacy Logic (Referer Match) + map "$http_referer|$http_host" $sec_legacy { + "~^https?://(?[^/:]+)(?::\d+)?/.*\|\k(?::\d+)?$" "TRUSTED"; + default "UNTRUSTED"; + } + + # Map 2: Strict Same-Origin Enforcement + map $http_sec_fetch_site $is_trusted { + "same-origin" "TRUSTED"; + "" $sec_legacy; # Fallback only if header is missing + default "UNTRUSTED"; # Blocks 'same-site' and 'cross-site' + } + # Virtual host config server { listen ${LISTEN_ADDR}:${PORT} default_server; @@ -102,6 +115,30 @@ http { index index.php; add_header X-Forwarded-Prefix "/app" always; + location /server/ { + # 1. Enforcement + if ($is_trusted != "TRUSTED") { + return 403; + } + + # 2. Path Rewriting & Proxy + rewrite ^/server/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:${BACKEND_PORT}; + + # 3. Performance & SSE (Per #1440) + proxy_buffering off; + proxy_cache off; + proxy_http_version 1.1; + proxy_set_header Connection ""; + client_max_body_size 50m; + proxy_read_timeout 3600s; + + # 4. Standard Proxy Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } location ~* \.php$ { # Set Cache-Control header to prevent caching on the first load diff --git a/install/production-filesystem/services/start-nginx.sh b/install/production-filesystem/services/start-nginx.sh index 7f17fbac..4d4bb668 100755 --- a/install/production-filesystem/services/start-nginx.sh +++ b/install/production-filesystem/services/start-nginx.sh @@ -42,9 +42,32 @@ if [ "$(id -u)" -eq 0 ]; then NGINX_USER_DIRECTIVE="user root;" fi +# ------------------------------------------------------------------ +# BACKEND_PORT RESOLUTION +# ------------------------------------------------------------------ +# Priority 1: APP_CONF_OVERRIDE (parsed via jq) +# Priority 2: GRAPHQL_PORT env var +# Priority 3: Default 20212 + +# Default +export BACKEND_PORT=20212 + +# Check env var +if [ -n "${GRAPHQL_PORT:-}" ]; then + export BACKEND_PORT="${GRAPHQL_PORT}" +fi + +# Check override (highest priority) +if [ -n "${APP_CONF_OVERRIDE:-}" ]; then + override_port=$(echo "${APP_CONF_OVERRIDE}" | jq -r '.GRAPHQL_PORT // empty') + if [ -n "${override_port}" ]; then + export BACKEND_PORT="${override_port}" + fi +fi + # Shell check doesn't recognize envsubst variables # shellcheck disable=SC2016 -if envsubst '${LISTEN_ADDR} ${PORT} ${NGINX_USER_DIRECTIVE}' < "${SYSTEM_NGINX_CONFIG_TEMPLATE}" > "${TEMP_CONFIG_FILE}" 2>/dev/null; then +if envsubst '${LISTEN_ADDR} ${PORT} ${NGINX_USER_DIRECTIVE} ${BACKEND_PORT}' < "${SYSTEM_NGINX_CONFIG_TEMPLATE}" > "${TEMP_CONFIG_FILE}" 2>/dev/null; then mv "${TEMP_CONFIG_FILE}" "${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}" else echo "Note: Unable to write to ${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}. Using default configuration." diff --git a/server/initialise.py b/server/initialise.py index f111ced0..e6bb2242 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -273,7 +273,7 @@ def importConfigs(pm, db, all_plugins): ) conf.BACKEND_API_URL = ccd( "BACKEND_API_URL", - "", + "/server", c_d, "API URL", '{"dataType":"string", "elements": [{"elementType" : "input", "elementOptions" : [] ,"transformers": []}]}', diff --git a/test/api_endpoints/test_nginx_proxy_security.py b/test/api_endpoints/test_nginx_proxy_security.py new file mode 100644 index 00000000..738f8f75 --- /dev/null +++ b/test/api_endpoints/test_nginx_proxy_security.py @@ -0,0 +1,141 @@ +import pytest +import requests +import os + +# Nginx listens on PORT, default 20211 +PORT = os.environ.get("PORT", "20211") +BACKEND_PORT = os.environ.get("BACKEND_PORT", "20212") +BASE_URL = f"http://localhost:{PORT}/server/" + +REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", 5)) + +def http_get(url, headers=None): + return requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT) + + +def test_nginx_proxy_security_modern_check(): + """ + Test that access is allowed when Sec-Fetch-Site is 'same-origin'. + """ + headers = { + "Sec-Fetch-Site": "same-origin" + } + try: + response = http_get(BASE_URL, headers=headers) + # 200 (OK), 401 (Auth), 404 (Not Found on backend), or 502 (Bad Gateway) means Nginx let it through. + # 403 means Nginx blocked it. + assert response.status_code in [200, 401, 404, 502], f"Expected access allowed, got {response.status_code}" + except requests.exceptions.ConnectionError: + pytest.fail("Could not connect to Nginx. Is it running?") + + +def test_nginx_proxy_security_legacy_check(): + """ + Test that access is allowed when Sec-Fetch-Site is missing but Referer matches host. + This is for old tablets/phones which are not updated in the last few years. + """ + headers = { + # No Sec-Fetch-Site + "Referer": f"http://localhost:{PORT}/some/page" + } + try: + response = http_get(BASE_URL, headers=headers) + assert response.status_code in [200, 401, 404, 502], f"Expected access allowed, got {response.status_code}" + except requests.exceptions.ConnectionError: + pytest.fail("Could not connect to Nginx. Is it running?") + + +def test_nginx_proxy_security_block_cross_site(): + """ + Test that access is BLOCKED when Sec-Fetch-Site is 'cross-site'. + """ + headers = { + "Sec-Fetch-Site": "cross-site" + } + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403, f"Expected 403 Forbidden, got {response.status_code}" + + +def test_nginx_proxy_security_block_no_headers(): + """ + Test that access is BLOCKED when no security headers are present. + """ + headers = {} + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403, f"Expected 403 Forbidden, got {response.status_code}" + + +def test_nginx_proxy_security_block_same_site(): + """ + Test that access is BLOCKED when Sec-Fetch-Site is 'same-site'. + (Strict same-origin enforcement) + """ + headers = {"Sec-Fetch-Site": "same-site"} + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403, f"Expected 403 for same-site, got {response.status_code}" + + +def test_nginx_proxy_security_block_referer_suffix_spoof(): + """ + Test that access is BLOCKED when Referer merely ends with the valid host. + """ + headers = {"Referer": f"http://attacker.com/path?target=localhost:{PORT}"} + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403 + + +def test_nginx_proxy_security_block_bad_referer(): + """ + Test that access is BLOCKED when Sec-Fetch-Site is missing and Referer is external. + """ + headers = { + "Referer": "http://evil.com/page" + } + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403, f"Expected 403 Forbidden, got {response.status_code}" + + +def test_nginx_proxy_security_block_subdomain_referer(): + """ + Test that access is BLOCKED when Referer is a subdomain (same-site, not same-origin). + """ + headers = { + "Referer": f"http://subdomain.localhost:{PORT}/" + } + response = http_get(BASE_URL, headers=headers) + assert response.status_code == 403, f"Expected 403 for subdomain referer, got {response.status_code}" + + +def test_nginx_proxy_security_legacy_protocol_agnostic(): + """ + Test that the legacy check allows both http and https referers. + """ + headers = {"Referer": f"https://localhost:{PORT}/path"} + response = http_get(BASE_URL, headers=headers) + assert response.status_code in [200, 401, 404, 502] + + +def test_nginx_proxy_security_block_server_docs(): + """ + Test that access to `/server/docs` is BLOCKED when navigating with browser (no referrer) + """ + url = f"http://localhost:{PORT}/server/docs" + try: + response = http_get(url) + # Backend may return 404 if it doesn't have the path; Nginx should never allow a 200 here. + assert response.status_code == 403, f"Expected 403 for /server/docs, got {response.status_code}" + except requests.exceptions.ConnectionError: + pytest.fail("Could not connect to Nginx. Is it running?") + + +def test_nginx_proxy_security_allow_port(): + """ + Test that access to `:20212/docs` is allowed by Nginx (should return 200). + """ + headers = {"Referer": f"https://localhost:{BACKEND_PORT}/path"} + url = f"http://localhost:{BACKEND_PORT}/docs" + try: + response = http_get(url, headers=headers) + assert response.status_code == 200, f"Expected 200 for /server/docs on allowed port, got {response.status_code}" + except requests.exceptions.ConnectionError: + pytest.fail("Could not connect to Nginx. Is it running?") diff --git a/test/docker_tests/run_docker_tests.sh b/test/docker_tests/run_docker_tests.sh index 675be28f..0c644a4b 100755 --- a/test/docker_tests/run_docker_tests.sh +++ b/test/docker_tests/run_docker_tests.sh @@ -17,6 +17,9 @@ else echo "ERROR: generate-configs.sh not found. Aborting." exit 1 fi +echo "Development $(git rev-parse --short=8 HEAD)" | tee ".VERSION" >/dev/null +date +%s > front/buildtimestamp.txt + # --- 2. Build the Docker Image --- echo "--- Building 'netalertx-dev-test' image ---" diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py index 1b28f9c8..4805d322 100644 --- a/test/docker_tests/test_docker_compose_scenarios.py +++ b/test/docker_tests/test_docker_compose_scenarios.py @@ -749,7 +749,7 @@ def test_custom_port_with_unwritable_nginx_config_compose() -> None: # Container should exit due to inability to write nginx config and custom port. assert result.returncode == 1 assert "unable to write to /tmp/nginx/active-config/netalertx.conf" in lowered_output - assert "mv: can't create '/tmp/nginx/active-config/nginx.conf'" in lowered_output + def test_host_network_compose(tmp_path: pathlib.Path) -> None: