Bug When Frappe runs behind HAProxy + nginx, the Origin header is stripped.

HI,

I found a bug When Frappe runs behind HAProxy + nginx, the Origin header is stripped.

Context:
HAPROXY
Frappe V16
PGSQL V18.1
REDIS
NGINX as proxy

Frappe’s SocketIO middleware (authenticate.js) rejects connections with ‘Invalid origin’ or ‘Invalid namespace’. The auth callback URL also
breaks because origin is undefined.

This patch fixes three files:
1. authenticate.js — null-safe origin check + return after next(error)
2. utils.js — fallback URL using site name when origin is None
3. system_health_report.js — null-safe toLowerCase() for PostgreSQL

------------------------------------------------------------------------------------------

def _patch_socketio_realtime(dictionary, step):
“”"Patch Frappe Node.js SocketIO files for reverse-proxy compatibility.

When Frappe runs behind HAProxy + nginx, the Origin header is stripped.
Frappe's SocketIO middleware (authenticate.js) rejects connections with
'Invalid origin' or 'Invalid namespace'. The auth callback URL also
breaks because origin is undefined. This patch fixes three files:

1. authenticate.js  — null-safe origin check + return after next(error)
2. utils.js         — fallback URL using site name when origin is None
3. system_health_report.js — null-safe toLowerCase() for PostgreSQL
"""
try:
    frappe_app = f"{BENCH_HOME}/apps/frappe/frappe"
    realtime = f"{BENCH_HOME}/apps/frappe/realtime"

    # -- authenticate.js: skip origin check when undefined (proxy strips
    #    it), add missing return statements after next(error) calls, and
    #    add explicit Host header in the Gunicorn auth-callback fetch.
    auth_js = f"{realtime}/middlewares/authenticate.js"
    auth_content = (
        r'const cookie = require("cookie");' "\n"
        r'const { get_conf, get_redis_subscriber } = require("../../node_utils");' "\n"
        r'const { get_url } = require("../utils");' "\n"
        r'const conf = get_conf();' "\n"
        r'const redisClient = get_redis_subscriber("redis_queue");' "\n"
        "\n"
        r'async function getSecretFromRedis() {' "\n"
        r'\tif (!redisClient.isOpen) await redisClient.connect();' "\n"
        r'\tconst val = await redisClient.get("socketio_auth_secret");' "\n"
        r'\treturn val;' "\n"
        r'}' "\n"
        "\n"
        r'function authenticate_with_frappe(socket, next) {' "\n"
        r'\tlet namespace = socket.nsp.name;' "\n"
        r"\tnamespace = namespace.slice(1, namespace.length);"
        r" // remove leading /" "\n"
        "\n"
        r'\tif (namespace != get_site_name(socket)) {' "\n"
        r'\t\tnext(new Error("Invalid namespace"));' "\n"
        r'\t\treturn;' "\n"
        r'\t}' "\n"
        "\n"
        r'\t// Skip origin check when behind reverse proxy (origin stripped)' "\n"
        r'\tif (socket.request.headers.origin && get_hostname(socket.request.headers.host)' "\n"
        r'\t\t!= get_hostname(socket.request.headers.origin)) {' "\n"
        r'\t\tnext(new Error("Invalid origin"));' "\n"
        r'\t\treturn;' "\n"
        r'\t}' "\n"
        "\n"
        r'\tif (!socket.request.headers.cookie && !socket.request.headers.authorization) {' "\n"
        r'\t\tnext(new Error("Missing cookie and authorization header."));' "\n"
        r'\t\treturn;' "\n"
        r'\t}' "\n"
        "\n"
        r'\tlet cookies = cookie.parse(socket.request.headers.cookie || "");' "\n"
        r'\tlet authorization_header = socket.request.headers.authorization;' "\n"
        "\n"
        r'\tif (!cookies.sid && !authorization_header) {' "\n"
        r'\t\tnext(new Error("No authentication method used."));' "\n"
        r'\t\treturn;' "\n"
        r'\t}' "\n"
        r'\tsocket.sid = cookies.sid;' "\n"
        r'\tsocket.authorization_header = authorization_header;' "\n"
        "\n"
        r'\tlet site_name = get_site_name(socket);' "\n"
        "\n"
        r'\tsocket.frappe_request = async (path, args = {}, opts = {}) => {' "\n"
        r'\t\tlet query_args = new URLSearchParams(args);' "\n"
        r'\t\tif (query_args.toString()) { path = path + "?" + query_args.toString(); }' "\n"
        r'\t\tlet headers = { "Host": site_name };' "\n"
        r'\t\tif (socket.authorization_header) {' "\n"
        r'\t\t\theaders["Authorization"] = socket.authorization_header;' "\n"
        r'\t\t} else if (socket.sid) {' "\n"
        r'\t\t\theaders["Cookie"] = `sid=${socket.sid}`;' "\n"
        r'\t\t}' "\n"
        r'\t\tconst secret = await getSecretFromRedis();' "\n"
        r'\t\tif (secret) { headers["X-Frappe-Socket-Secret"] = secret; }' "\n"
        r'\t\treturn fetch(get_url(socket, path), { ...opts, headers });' "\n"
        r'\t};' "\n"
        "\n"
        r'\tsocket' "\n"
        r'\t\t.frappe_request("/api/method/frappe.realtime.get_user_info")' "\n"
        r'\t\t.then((res) => res.json())' "\n"
        r'\t\t.then(async ({ message }) => {' "\n"
        r'\t\t\tif (socket.user !== "Guest" && !message.installed_apps) {' "\n"
        r'\t\t\t\tconst retry_res = await socket.frappe_request(' "\n"
        r'\t\t\t\t\t"/api/method/frappe.realtime.get_user_info"' "\n"
        r'\t\t\t\t);' "\n"
        r'\t\t\t\tconst retry_data = await retry_res.json();' "\n"
        r'\t\t\t\tmessage = retry_data.message;' "\n"
        r'\t\t\t}' "\n"
        r'\t\t\tsocket.user = message.user;' "\n"
        r'\t\t\tsocket.user_type = message.user_type;' "\n"
        r'\t\t\tsocket.installed_apps = message.installed_apps || [];' "\n"
        r'\t\t\tnext();' "\n"
        r'\t\t})' "\n"
        r'\t\t.catch((e) => { next(new Error(`Unauthorized: ${e}`)); });' "\n"
        r'}' "\n"
        "\n"
        r'function get_site_name(socket) {' "\n"
        r'\tif (socket.site_name) { return socket.site_name; }' "\n"
        r'\telse if (socket.request.headers["x-frappe-site-name"]) {' "\n"
        r'\t\tsocket.site_name = get_hostname(socket.request.headers["x-frappe-site-name"]);' "\n"
        r'\t} else if (conf.default_site &&' "\n"
        r'\t\t["localhost", "127.0.0.1"].indexOf(get_hostname(socket.request.headers.host)) !== -1) {' "\n"
        r'\t\tsocket.site_name = conf.default_site;' "\n"
        r'\t} else if (socket.request.headers.origin) {' "\n"
        r'\t\tsocket.site_name = get_hostname(socket.request.headers.origin);' "\n"
        r'\t} else {' "\n"
        r'\t\tsocket.site_name = get_hostname(socket.request.headers.host);' "\n"
        r'\t}' "\n"
        r'\treturn socket.site_name;' "\n"
        r'}' "\n"
        "\n"
        r'function get_hostname(url) {' "\n"
        r'\tif (!url) return undefined;' "\n"
        r'\tif (url.indexOf("://") > -1) { url = url.split("/")[2]; }' "\n"
        r'\treturn url.match(/:/g) ? url.slice(0, url.indexOf(":")) : url;' "\n"
        r'}' "\n"
        "\n"
        r'module.exports = authenticate_with_frappe;' "\n"
    )

    # -- utils.js: fallback to http://sitename when origin is absent
    utils_js = f"{realtime}/utils.js"
    utils_content = (
        r'const { get_conf } = require("../node_utils");' "\n"
        r'const conf = get_conf();' "\n"
        "\n"
        r'function get_url(socket, path) {' "\n"
        r'\tif (!path) { path = ""; }' "\n"
        r'\tlet url = socket.request.headers.origin;' "\n"
        r'\t// When origin is absent (reverse proxy strips it), build URL' "\n"
        r'\t// from the namespace (site name) so Node.js fetch() sends the' "\n"
        r'\t// correct Host header to nginx/Gunicorn.' "\n"
        r'\tif (!url) {' "\n"
        r'\t\tlet site_name = socket.nsp.name.slice(1);' "\n"
        r'\t\turl = "http://" + site_name;' "\n"
        r'\t}' "\n"
        r'\tif (conf.developer_mode) {' "\n"
        r'\t\tlet [protocol, host, port] = url.split(":");' "\n"
        r'\t\tport = conf.webserver_port;' "\n"
        r'\t\turl = `${protocol}:${host}:${port}`;' "\n"
        r'\t}' "\n"
        r'\treturn url + path;' "\n"
        r'}' "\n"
        "\n"
        r'module.exports = { get_url };' "\n"
    )

    # -- system_health_report.js: null-safe toLowerCase() for PostgreSQL
    #    (binary_logging, scheduler_status, background_jobs_check can be
    #    undefined/null with PostgreSQL — undefined.toLowerCase() crashes)
    health_js = (
        f"{frappe_app}/desk/doctype/"
        f"system_health_report/system_health_report.js"
    )

    import base64 as _b64  # local import — already imported at module level
    auth_b64 = _b64.b64encode(auth_content.encode()).decode()
    utils_b64 = _b64.b64encode(utils_content.encode()).decode()

    # Build sed patterns as plain variables to avoid linter
    # parse errors from nested escaped quotes in f-strings.
    _q = "'"  # bash single-quote delimiter for sed
    _s_sched = (
        "s/scheduler_status: (val) => val.toLowerCase() != "
        '"active"/'
        "scheduler_status: (val) => !val || val.toLowerCase() != "
        '"active"/'
    )
    _s_bg = (
        "s/background_jobs_check: (val) => val.toLowerCase() != "
        '"finished"/'
        "background_jobs_check: (val) => !val || val.toLowerCase() != "
        '"finished"/'
    )
    _s_bl = (
        "s/binary_logging: (val) => val.toLowerCase() != "
        '"on"/'
        r"binary_logging: (val) => val \&\& val.toLowerCase \&\& "
        'val.toLowerCase() != "on"/'
    )
    commands = [
        # Write authenticate.js via base64 to avoid shell escaping
        f"echo '{auth_b64}' | base64 -d > {auth_js}",
        # Write utils.js via base64 to avoid shell escaping
        f"echo '{utils_b64}' | base64 -d > {utils_js}",
        # Patch system_health_report.js — null-safe toLowerCase()
        f"sed -i {_q}{_s_sched}{_q} {health_js}",
        f"sed -i {_q}{_s_bg}{_q} {health_js}",
        f"sed -i {_q}{_s_bl}{_q} {health_js}",
    ]
    success, error, output = _run_phase(
        dictionary, "TUNING-SOCKETIO-PATCH", commands, step
    )
    if not success:
        logger.warning(
            "[_patch_socketio_realtime] Patch failed (non-fatal).",
            extra={"stepname": step}
        )
except Exception as exc:
    logger.warning(
        f"[_patch_socketio_realtime] Exception (non-fatal): {exc}",
        extra={"stepname": step}
    )