Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]

Expand Down
14 changes: 14 additions & 0 deletions backend/SC4SNMP_UI_backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +98,19 @@ def create_app():

app = Flask(__name__)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProxyFix defaults x_proto to 1, so configuring only x_for also trusts X-Forwarded-Proto. Is this intentional?

Any random value such as abc can become Flask's forwarded-scheme can we make this configurable(UI.backEnd.trustedProxyProtoCount), default it to 0, and accept only valid HTTP/HTTPS schemes from a verified proxy chain when enabled?


auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() == "true"
if auth_enabled:
missing = []
Expand Down
37 changes: 37 additions & 0 deletions backend/tests/ui_handling/test_trusted_proxy.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**/node_modules
**/dist
lerna-debug.log
19 changes: 14 additions & 5 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 []
36 changes: 36 additions & 0 deletions frontend/nginx/default.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
server {
listen ${DEMO_PORT};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would only listen to IPv4 right? do we have any plan for IPv6 support for UI? if we can do the same in this PR would be good ?

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/ {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ca we set absolute_redirect off inside the /api/ location ? so the redirect uses /api/ and preserves the public scheme, host, and port?

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}/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BACKEND_URL accepts HTTPS, but nginx does not verify the backend certificate or send SNI by default. It can therefore accept an untrusted certificate (which i tested in my environment) or one belonging to a different server. This could then applies to frontend->to->backend HTTPS

}

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;
}
}
56 changes: 56 additions & 0 deletions frontend/nginx/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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" ;;

@gmaiyani gmaiyani Oct 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we plan for IPv6 then this regex would fail as it will reject the valid IPv6
for ex: http://[::1]:5000

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;'
26 changes: 26 additions & 0 deletions frontend/nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions frontend/nginx/security-headers.conf
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 13 additions & 7 deletions frontend/packages/manager/demo/webpack.standalone.config.js
Original file line number Diff line number Diff line change
@@ -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:{
Expand All @@ -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 }
});
3 changes: 1 addition & 2 deletions frontend/packages/manager/src/api.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
4 changes: 0 additions & 4 deletions frontend/packages/manager/src/host.jsx

This file was deleted.

13 changes: 13 additions & 0 deletions frontend/packages/manager/src/tests/api.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading