From c86cb82b9b7336afecbf101a53e6e009eebec413 Mon Sep 17 00:00:00 2001 From: ajasnosz Date: Tue, 20 Oct 2026 15:53:49 +0200 Subject: [PATCH 1/3] feat: add reverse proxy --- backend/SC4SNMP_UI_backend/__init__.py | 16 +++++- .../tests/ui_handling/test_trusted_proxy.py | 37 ++++++++++++ frontend/.dockerignore | 3 + frontend/Dockerfile | 19 +++++-- frontend/nginx/default.conf.template | 36 ++++++++++++ frontend/nginx/entrypoint.sh | 56 +++++++++++++++++++ frontend/nginx/nginx.conf | 26 +++++++++ frontend/nginx/security-headers.conf | 4 ++ .../manager/demo/webpack.standalone.config.js | 20 ++++--- frontend/packages/manager/src/api.js | 3 +- frontend/packages/manager/src/host.jsx | 4 -- .../packages/manager/src/tests/api.test.js | 13 +++++ 12 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 backend/tests/ui_handling/test_trusted_proxy.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/nginx/default.conf.template create mode 100644 frontend/nginx/entrypoint.sh create mode 100644 frontend/nginx/nginx.conf create mode 100644 frontend/nginx/security-headers.conf delete mode 100644 frontend/packages/manager/src/host.jsx create mode 100644 frontend/packages/manager/src/tests/api.test.js diff --git a/backend/SC4SNMP_UI_backend/__init__.py b/backend/SC4SNMP_UI_backend/__init__.py index 007fa0b..f239b4e 100644 --- a/backend/SC4SNMP_UI_backend/__init__.py +++ b/backend/SC4SNMP_UI_backend/__init__.py @@ -8,6 +8,7 @@ from flask_limiter.util import get_remote_address from pymongo import MongoClient from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError +from werkzeug.middleware.proxy_fix import ProxyFix import os import logging from celery import Celery @@ -97,7 +98,20 @@ def create_app(): app = Flask(__name__) - auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() == "true" + trusted_proxy_count_env = os.getenv("TRUSTED_PROXY_COUNT", "0").strip() or "0" + try: + trusted_proxy_count = int(trusted_proxy_count_env) + except ValueError: + trusted_proxy_count = -1 + if trusted_proxy_count < 0: + raise ValueError( + f"TRUSTED_PROXY_COUNT must be a non-negative integer, got {trusted_proxy_count_env!r}." + ) + if trusted_proxy_count > 0: + # Per-client rate limiting needs the real client IP from X-Forwarded-For. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=trusted_proxy_count) + + auth_enabled =os.getenv("AUTH_ENABLED", "true").lower() == "true" if auth_enabled: missing = [] for var in ("AUTH_USERNAME", "AUTH_PASSWORD_HASH", "JWT_SECRET"): diff --git a/backend/tests/ui_handling/test_trusted_proxy.py b/backend/tests/ui_handling/test_trusted_proxy.py new file mode 100644 index 0000000..a856d3e --- /dev/null +++ b/backend/tests/ui_handling/test_trusted_proxy.py @@ -0,0 +1,37 @@ +import pytest +from flask_limiter.util import get_remote_address + +FORWARDED = {"X-Forwarded-For": "203.0.113.7"} + + +def _probe_ip(app): + @app.route("/_probe_ip") + def probe_ip(): + return get_remote_address() + + return app.test_client().get("/_probe_ip", headers=FORWARDED, environ_base={"REMOTE_ADDR": "10.0.0.5"}) + + +def test_forwarded_for_ignored_by_default(app): + assert _probe_ip(app).get_data(as_text=True) == "10.0.0.5" + + +def test_forwarded_for_honored_with_trusted_proxy(monkeypatch): + import SC4SNMP_UI_backend + from unittest import mock + + monkeypatch.setenv("TRUSTED_PROXY_COUNT", "1") + with mock.patch("SC4SNMP_UI_backend.wait_for_mongodb_replicaset"): + app = SC4SNMP_UI_backend.create_app() + assert _probe_ip(app).get_data(as_text=True) == "203.0.113.7" + + +@pytest.mark.parametrize("value", ["abc", "-1", "1.5"]) +def test_invalid_trusted_proxy_count_raises(monkeypatch, value): + import SC4SNMP_UI_backend + from unittest import mock + + monkeypatch.setenv("TRUSTED_PROXY_COUNT", value) + with mock.patch("SC4SNMP_UI_backend.wait_for_mongodb_replicaset"): + with pytest.raises(ValueError, match="TRUSTED_PROXY_COUNT"): + SC4SNMP_UI_backend.create_app() diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e0c42a8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/dist +lerna-debug.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0b4bebd..47faf94 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,21 @@ -FROM node:24-alpine AS build-step +# The bundle is architecture-independent, so build it once on the build platform. +FROM --platform=$BUILDPLATFORM node:24-alpine AS build-step WORKDIR /frontend ENV PATH=/frontend/node_modules/.bin:$PATH COPY package.json yarn.lock lerna.json ./ COPY ./packages ./packages RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* -RUN yarn install +RUN yarn install --frozen-lockfile RUN yarn run build -RUN apk add curl -WORKDIR /frontend/packages/manager + +FROM nginxinc/nginx-unprivileged:1.30.6-alpine +COPY --from=build-step /frontend/packages/manager/dist /usr/share/nginx/html +COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY nginx/default.conf.template nginx/security-headers.conf /etc/nginx/sc4snmp/ +COPY --chmod=755 nginx/entrypoint.sh /usr/local/bin/sc4snmp-entrypoint.sh + +ENV DEMO_PORT=8080 \ + BACKEND_URL=http://ui-backend-entrypoint:5000 EXPOSE 8080 -CMD ["yarn", "run", "start:demo"] +ENTRYPOINT ["/usr/local/bin/sc4snmp-entrypoint.sh"] +CMD [] diff --git a/frontend/nginx/default.conf.template b/frontend/nginx/default.conf.template new file mode 100644 index 0000000..9cf1b3e --- /dev/null +++ b/frontend/nginx/default.conf.template @@ -0,0 +1,36 @@ +server { + listen ${DEMO_PORT}; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Bulk device import and config restore can exceed nginx's 1 MB default. + client_max_body_size 10m; + + gzip on; + gzip_min_length 1024; + gzip_types text/css application/javascript image/svg+xml; + + # The browser only talks to this origin; API calls are forwarded to the backend. + location /api/ { + include /etc/nginx/sc4snmp/security-headers.conf; + gzip off; + proxy_http_version 1.1; + proxy_set_header Host $host; + # Overwritten (not appended) so clients cannot inject an address; $remote_addr + # is the real client IP when the request came from a trusted proxy (see realip). + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass ${BACKEND_URL}/; + } + + location = /index.html { + include /etc/nginx/sc4snmp/security-headers.conf; + add_header Cache-Control "no-cache" always; + } + + location / { + include /etc/nginx/sc4snmp/security-headers.conf; + try_files $uri /index.html; + } +} diff --git a/frontend/nginx/entrypoint.sh b/frontend/nginx/entrypoint.sh new file mode 100644 index 0000000..246011d --- /dev/null +++ b/frontend/nginx/entrypoint.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -eu + +DEMO_PORT="${DEMO_PORT:-8080}" +BACKEND_URL="${BACKEND_URL:-http://ui-backend-entrypoint:5000}" +TRUSTED_PROXY_CIDRS="$(printf '%s' "${TRUSTED_PROXY_CIDRS:-}" | tr -d ' ')" + +fail() { + echo "sc4snmp-ui-frontend: $*" >&2 + exit 1 +} + +# Values are rendered into the nginx config, so reject anything outside a strict whitelist. +case "$DEMO_PORT" in + ''|*[!0-9]*) fail "DEMO_PORT must be a port number, got '$DEMO_PORT'" ;; +esac +[ "$DEMO_PORT" -ge 1 ] && [ "$DEMO_PORT" -le 65535 ] || fail "DEMO_PORT out of range: $DEMO_PORT" + +case "$BACKEND_URL" in + *[!A-Za-z0-9.:/-]*) fail "BACKEND_URL contains invalid characters" ;; +esac +printf '%s\n' "$BACKEND_URL" | grep -Eq '^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$' \ + || fail "BACKEND_URL must look like http://host[:port]" + +CONF_DIR=/tmp/nginx/conf.d +mkdir -p "$CONF_DIR" +rm -f "$CONF_DIR"/*.conf + +envsubst '${DEMO_PORT} ${BACKEND_URL}' \ + < /etc/nginx/sc4snmp/default.conf.template > "$CONF_DIR/default.conf" + +if [ -n "$TRUSTED_PROXY_CIDRS" ]; then + case "$TRUSTED_PROXY_CIDRS" in + *[!0-9A-Fa-f:./,]*) fail "TRUSTED_PROXY_CIDRS contains invalid characters" ;; + esac + : > "$CONF_DIR/00-realip.conf" + OLD_IFS="$IFS" + IFS=',' + set -f + for cidr in $TRUSTED_PROXY_CIDRS; do + if printf '%s\n' "$cidr" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' \ + || printf '%s\n' "$cidr" | grep -Eq '^[0-9A-Fa-f:]*:[0-9A-Fa-f:]*(/[0-9]{1,3})?$'; then + echo "set_real_ip_from $cidr;" >> "$CONF_DIR/00-realip.conf" + else + fail "TRUSTED_PROXY_CIDRS entry is not an IP or CIDR: '$cidr'" + fi + done + set +f + IFS="$OLD_IFS" + { + echo "real_ip_header X-Forwarded-For;" + echo "real_ip_recursive on;" + } >> "$CONF_DIR/00-realip.conf" +fi + +exec nginx -g 'daemon off;' diff --git a/frontend/nginx/nginx.conf b/frontend/nginx/nginx.conf new file mode 100644 index 0000000..d0c0bc5 --- /dev/null +++ b/frontend/nginx/nginx.conf @@ -0,0 +1,26 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + access_log /dev/stdout; + sendfile on; + keepalive_timeout 65; + + # The root filesystem is read-only in the Helm chart; /tmp is the only writable path. + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + include /tmp/nginx/conf.d/*.conf; +} diff --git a/frontend/nginx/security-headers.conf b/frontend/nginx/security-headers.conf new file mode 100644 index 0000000..aa20d5f --- /dev/null +++ b/frontend/nginx/security-headers.conf @@ -0,0 +1,4 @@ +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; +add_header X-Frame-Options "DENY" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "same-origin" always; diff --git a/frontend/packages/manager/demo/webpack.standalone.config.js b/frontend/packages/manager/demo/webpack.standalone.config.js index c312f46..4cd1fda 100644 --- a/frontend/packages/manager/demo/webpack.standalone.config.js +++ b/frontend/packages/manager/demo/webpack.standalone.config.js @@ -1,9 +1,10 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const webpack = require('webpack'); const { merge } = require('webpack-merge'); const baseConfig = require('@splunk/webpack-configs/base.config').default; +const isProduction = process.env.NODE_ENV === 'production'; + module.exports = merge(baseConfig, { entry: path.join(__dirname, 'demo'), output:{ @@ -19,12 +20,17 @@ module.exports = merge(baseConfig, { hash: true, template: path.join(__dirname, 'standalone/index.html'), }), - new webpack.DefinePlugin({ - 'process.env':{ - 'REACT_APP_FLASK_PORT': JSON.stringify(process.env.REACT_APP_FLASK_PORT || 5000), - } - }) ], - devtool: 'eval-source-map', + // eval-based source maps would force 'unsafe-eval' in the CSP of the served bundle. + devtool: isProduction ? false : 'eval-source-map', + devServer: { + proxy: [ + { + context: ['/api'], + target: `http://localhost:${process.env.REACT_APP_FLASK_PORT || 5000}`, + pathRewrite: { '^/api': '' }, + }, + ], + }, performance: { hints: false } }); diff --git a/frontend/packages/manager/src/api.js b/frontend/packages/manager/src/api.js index c4b467a..97e2b1f 100644 --- a/frontend/packages/manager/src/api.js +++ b/frontend/packages/manager/src/api.js @@ -1,10 +1,9 @@ import axios from 'axios'; -import { backendHost } from './host'; // axios.create is a documented static method on the default export, not a named export. // eslint-disable-next-line import/no-named-as-default-member const api = axios.create({ - baseURL: `http://${backendHost}`, + baseURL: '/api', withCredentials: true, headers: { 'X-Requested-With': 'XMLHttpRequest', diff --git a/frontend/packages/manager/src/host.jsx b/frontend/packages/manager/src/host.jsx deleted file mode 100644 index 8602cc3..0000000 --- a/frontend/packages/manager/src/host.jsx +++ /dev/null @@ -1,4 +0,0 @@ -const backendPort = process.env.REACT_APP_FLASK_PORT; -const backendIp = window.location.host.split(/(:\d.)/)[0] -const backendHost = `${backendIp}:${backendPort}`; -export {backendHost}; diff --git a/frontend/packages/manager/src/tests/api.test.js b/frontend/packages/manager/src/tests/api.test.js new file mode 100644 index 0000000..5cb4424 --- /dev/null +++ b/frontend/packages/manager/src/tests/api.test.js @@ -0,0 +1,13 @@ +import { expect, describe, it } from "@jest/globals"; +import api from "../api"; + +describe("api client", () => { + it("calls the backend through the same-origin /api prefix", () => { + expect(api.defaults.baseURL).toBe("/api"); + }); + + it("sends credentials and the CSRF header", () => { + expect(api.defaults.withCredentials).toBe(true); + expect(api.defaults.headers["X-Requested-With"]).toBe("XMLHttpRequest"); + }); +}); From 39e179d3619ac89f396ded08433eeb2816ba9a94 Mon Sep 17 00:00:00 2001 From: ajasnosz Date: Thu, 22 Oct 2026 09:09:54 +0200 Subject: [PATCH 2/3] feat: update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e42abf4..cce1156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ### Changed +- serve the frontend from an unprivileged nginx with the production build instead of the webpack development server; the container starts immediately and runs with a read-only root filesystem +- the browser now calls the API on its own origin under `/api`, which the frontend nginx forwards to the backend (`BACKEND_URL`, default `http://ui-backend-entrypoint:5000`); the backend no longer needs to be reachable from the browser, and `REACT_APP_FLASK_PORT` is no longer used by the image +- add security headers (Content-Security-Policy without `unsafe-eval`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`); the production bundle no longer contains `eval` source maps and is much smaller +- add `TRUSTED_PROXY_COUNT` (backend) and `TRUSTED_PROXY_CIDRS` (frontend) so the login rate limit is applied per client when the UI is behind a reverse proxy +- the UI can be served behind a TLS reverse proxy without patching the running pods ## [1.3.2] From de977e78199888347c0d6cb67797021a6564ff2c Mon Sep 17 00:00:00 2001 From: ajasnosz Date: Fri, 23 Oct 2026 15:14:38 +0200 Subject: [PATCH 3/3] feat: update CHANGELOG.md --- backend/SC4SNMP_UI_backend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/SC4SNMP_UI_backend/__init__.py b/backend/SC4SNMP_UI_backend/__init__.py index f239b4e..0954c02 100644 --- a/backend/SC4SNMP_UI_backend/__init__.py +++ b/backend/SC4SNMP_UI_backend/__init__.py @@ -111,7 +111,7 @@ def create_app(): # Per-client rate limiting needs the real client IP from X-Forwarded-For. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=trusted_proxy_count) - auth_enabled =os.getenv("AUTH_ENABLED", "true").lower() == "true" + auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() == "true" if auth_enabled: missing = [] for var in ("AUTH_USERNAME", "AUTH_PASSWORD_HASH", "JWT_SECRET"):