From b9ac9893821aba99bf443e8da9d8f48bf4ac1e7d Mon Sep 17 00:00:00 2001 From: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:52:12 +0800 Subject: [PATCH] fix(proxy): bracket IPv6 hosts on custom locations --- backend/internal/nginx.js | 12 ++++---- backend/internal/upstream_host.js | 37 +++++++++++++++++++++++++ backend/internal/upstream_host.test.js | 38 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 backend/internal/upstream_host.js create mode 100644 backend/internal/upstream_host.test.js diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index a50e0780dc..2349ce3716 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -7,6 +7,7 @@ import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; +import { prepareLocationForward } from "./upstream_host.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -187,11 +188,12 @@ const internalNginx = { locationCopy.access_list = host.access_list; } - if (locationCopy.forward_host.indexOf("/") > -1) { - const splitted = locationCopy.forward_host.split("/"); - - locationCopy.forward_host = splitted.shift(); - locationCopy.forward_path = `/${splitted.join("/")}`; + // A slash in forward_host is a path suffix. An IPv6 literal must then + // be bracketed or "host:port" is an invalid upstream. + const forward = prepareLocationForward(locationCopy.forward_host); + locationCopy.forward_host = forward.forward_host; + if (forward.forward_path !== undefined) { + locationCopy.forward_path = forward.forward_path; } renderedLocations += await renderEngine.parseAndRender(template, locationCopy); diff --git a/backend/internal/upstream_host.js b/backend/internal/upstream_host.js new file mode 100644 index 0000000000..e8f7f2667b --- /dev/null +++ b/backend/internal/upstream_host.js @@ -0,0 +1,37 @@ +import net from "node:net"; + +/** + * An IPv6 literal must be wrapped in square brackets before nginx appends + * ":". Otherwise proxy_pass is rejected with "invalid port in upstream". + * Hostnames, IPv4 addresses, and values that are already bracketed are unchanged. + * + * @param {string} host + * @returns {string} + */ +export function formatUpstreamHost(host) { + if (typeof host === "string" && net.isIPv6(host)) { + return `[${host}]`; + } + return host; +} + +/** + * Split an optional path suffix off a custom-location forward host, then + * bracket an IPv6 address. `forward_path` is omitted when the host has no slash. + * + * @param {string} forwardHost + * @returns {{forward_host: string, forward_path?: string}} + */ +export function prepareLocationForward(forwardHost) { + let host = forwardHost; + let forwardPath; + if (host.indexOf("/") > -1) { + const splitted = host.split("/"); + host = splitted.shift(); + forwardPath = `/${splitted.join("/")}`; + } + return { + forward_host: formatUpstreamHost(host), + forward_path: forwardPath, + }; +} diff --git a/backend/internal/upstream_host.test.js b/backend/internal/upstream_host.test.js new file mode 100644 index 0000000000..f5fc82b041 --- /dev/null +++ b/backend/internal/upstream_host.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { prepareLocationForward } from "./upstream_host.js"; + +const locationTemplate = fs.readFileSync(new URL("../templates/_location.conf", import.meta.url), "utf8"); +const nginxSource = fs.readFileSync(new URL("./nginx.js", import.meta.url), "utf8"); + +function renderLocationProxyPass(forwardHost, forwardPort, forwardPath = "") { + const forward = prepareLocationForward(forwardHost); + const path = forward.forward_path !== undefined ? forward.forward_path : forwardPath; + return `proxy_pass http://${forward.forward_host}:${forwardPort}${path};`; +} + +test("custom location template appends the port directly to forward_host", () => { + assert.match( + locationTemplate, + /proxy_pass\s+\{\{\s*forward_scheme\s*\}\}:\/\/\{\{\s*forward_host\s*\}\}:\{\{\s*forward_port\s*\}\}/, + ); + assert.match(nginxSource, /prepareLocationForward\(locationCopy\.forward_host\)/); +}); + +test("ipv6 custom location upstreams are bracketed before the port", () => { + assert.equal( + renderLocationProxyPass("fe80::528:3c87:e7bb:ab08", 25), + "proxy_pass http://[fe80::528:3c87:e7bb:ab08]:25;", + ); + assert.equal(renderLocationProxyPass("::1", 8080), "proxy_pass http://[::1]:8080;"); + assert.equal(renderLocationProxyPass("::1/api", 8080), "proxy_pass http://[::1]:8080/api;"); + assert.equal(renderLocationProxyPass("2001:db8::1", 443, "/health"), "proxy_pass http://[2001:db8::1]:443/health;"); +}); + +test("ipv4, hostnames, and already bracketed addresses are left unchanged", () => { + assert.equal(renderLocationProxyPass("192.168.1.10", 8080), "proxy_pass http://192.168.1.10:8080;"); + assert.equal(renderLocationProxyPass("backend.internal", 80), "proxy_pass http://backend.internal:80;"); + assert.equal(renderLocationProxyPass("example.com/api", 80), "proxy_pass http://example.com:80/api;"); + assert.equal(renderLocationProxyPass("[::1]", 8080), "proxy_pass http://[::1]:8080;"); +});