Skip to content

Commit 0cf643a

Browse files
committed
fix(xo-server): keep localhost proxy targets on wildcard binds
Since 82f047e (#9572), [http.proxies] targets pointing at localhost are rewritten to the hostname of the HTTPS listen config. With the default XOA config (hostname = '0.0.0.0') this produced targets such as http://0.0.0.0:9004/metrics and, worse, routed these internal requests through the proxy-aware HTTP agent: with an httpProxy configured, /openmetrics scrapes left the appliance for the corporate proxy and were denied there. Only rewrite when the bind address is a specific one (the #9500 case, where localhost is not reachable) and pick the agent from the original target locality, so requests to the machine itself never go through the HTTP proxy. Seen in support ticket 7763112.
1 parent a67dd73 commit 0cf643a

4 files changed

Lines changed: 118 additions & 36 deletions

File tree

‎CHANGELOG.unreleased.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@
3333
3434
<!--packages-start-->
3535

36+
- xo-server patch
37+
3638
<!--packages-end-->
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// hostnames on which the server listens on all interfaces: `localhost` is
2+
// reachable there, rewriting it would produce an unroutable target (0.0.0.0)
3+
// and route the request through the HTTP proxy if one is configured
4+
// (https://help.vates.tech/#ticket/zoom/63328)
5+
const WILDCARD_HOSTNAMES = new Set(['0.0.0.0', '::'])
6+
7+
/**
8+
* Resolves a `[http.proxies]` target URL.
9+
*
10+
* `isLocal` tells whether the configured target points at this very machine
11+
* (`localhost`, possibly rewritten to the bound address): such requests must
12+
* be sent directly, never through the HTTP proxy agent.
13+
*
14+
* @param {string} url
15+
* @param {'ws:' | 'http:'} protocol
16+
* @param {object} userHttpConfig - the `http.listen` entry used by the web UI
17+
* @returns {{ targetUrl: URL, isLocal: boolean }}
18+
*/
19+
export function getProxyTargetUrl(url, protocol, userHttpConfig) {
20+
let target = url
21+
22+
if (target.includes('[port]')) {
23+
target = target.replace(/\[port\]/g, userHttpConfig.port)
24+
}
25+
let dynamicProtocol = false
26+
if (target.includes('[protocol]')) {
27+
dynamicProtocol = true
28+
target = target.replace(/\[protocol\]/g, protocol)
29+
}
30+
31+
const targetUrl = new URL(target)
32+
if (dynamicProtocol && userHttpConfig.key !== undefined) {
33+
targetUrl.protocol = targetUrl.protocol === 'ws:' ? 'wss:' : 'https:'
34+
}
35+
36+
const isLocal = targetUrl.hostname === 'localhost'
37+
const { hostname } = userHttpConfig
38+
if (isLocal && hostname !== undefined && !WILDCARD_HOSTNAMES.has(hostname)) {
39+
targetUrl.hostname = hostname
40+
}
41+
42+
return { targetUrl, isLocal }
43+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import assert from 'assert/strict'
2+
import test from 'node:test'
3+
4+
import { getProxyTargetUrl } from './_getProxyTargetUrl.mjs'
5+
6+
const { describe, it } = test
7+
8+
describe('getProxyTargetUrl()', () => {
9+
it('keeps localhost untouched when no hostname is configured', () => {
10+
const { targetUrl, isLocal } = getProxyTargetUrl('http://localhost:9004', 'http:', { port: 80 })
11+
assert.equal(targetUrl.href, 'http://localhost:9004/')
12+
assert.equal(isLocal, true)
13+
})
14+
15+
// regression test: with the shipped XOA config (hostname = '0.0.0.0'), the
16+
// target must not be rewritten to an unroutable address nor become eligible
17+
// for the HTTP proxy agent
18+
it('keeps localhost untouched when the listen hostname is a wildcard', () => {
19+
for (const hostname of ['0.0.0.0', '::']) {
20+
const { targetUrl, isLocal } = getProxyTargetUrl('http://localhost:9004', 'http:', { hostname, port: 443 })
21+
assert.equal(targetUrl.href, 'http://localhost:9004/')
22+
assert.equal(isLocal, true)
23+
}
24+
})
25+
26+
it('rewrites localhost to a specific listen hostname, still marked local', () => {
27+
const { targetUrl, isLocal } = getProxyTargetUrl('http://localhost:9004', 'http:', {
28+
hostname: '192.168.1.5',
29+
port: 443,
30+
})
31+
assert.equal(targetUrl.href, 'http://192.168.1.5:9004/')
32+
assert.equal(isLocal, true)
33+
})
34+
35+
it('leaves non-localhost targets alone and marks them non-local', () => {
36+
const { targetUrl, isLocal } = getProxyTargetUrl('http://metrics.lan:1234/foo', 'http:', {
37+
hostname: '192.168.1.5',
38+
port: 443,
39+
})
40+
assert.equal(targetUrl.href, 'http://metrics.lan:1234/foo')
41+
assert.equal(isLocal, false)
42+
})
43+
44+
it('substitutes [port] and [protocol], upgrading the protocol on secure configs', () => {
45+
const { targetUrl } = getProxyTargetUrl('[protocol]//localhost:[port]/api', 'http:', {
46+
hostname: '0.0.0.0',
47+
port: 8443,
48+
key: 'dummy',
49+
})
50+
assert.equal(targetUrl.href, 'https://localhost:8443/api')
51+
})
52+
53+
it('upgrades ws to wss on secure configs', () => {
54+
const { targetUrl } = getProxyTargetUrl('[protocol]//localhost:[port]/api', 'ws:', {
55+
hostname: '10.0.0.1',
56+
port: 8443,
57+
key: 'dummy',
58+
})
59+
assert.equal(targetUrl.href, 'wss://10.0.0.1:8443/api')
60+
})
61+
62+
it('does not upgrade the protocol when it is not dynamic', () => {
63+
const { targetUrl } = getProxyTargetUrl('ws://localhost:9001', 'ws:', { hostname: '0.0.0.0', port: 443, key: 'k' })
64+
assert.equal(targetUrl.href, 'ws://localhost:9001/')
65+
})
66+
})

‎packages/xo-server/src/index.mjs‎

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ms from 'ms'
1616
import once from 'lodash/once.js'
1717
import proxyAddr from 'proxy-addr'
1818
import proxyConsole from './proxy-console.mjs'
19+
import { getProxyTargetUrl } from './_getProxyTargetUrl.mjs'
1920
import pw from 'pw'
2021
import serveStatic from 'serve-static'
2122
import stoppable from 'stoppable'
@@ -685,36 +686,6 @@ const setUpProxies = (express, opts, xo) => {
685686
})
686687
})
687688

688-
/**
689-
*
690-
* @param {string} url
691-
* @param {'ws:' | 'http:'} protocol
692-
* @returns {URL} targetUrl
693-
*/
694-
function getTargetUrl(url, protocol) {
695-
let target = url
696-
697-
if (target.includes('[port]')) {
698-
target = target.replace(/\[port\]/g, userHttpConfig.port)
699-
}
700-
let dynamicProtocol = false
701-
if (target.includes('[protocol]')) {
702-
dynamicProtocol = true
703-
target = target.replace(/\[protocol\]/g, protocol)
704-
}
705-
706-
const targetUrl = new URL(target)
707-
if (dynamicProtocol && isSecure) {
708-
targetUrl.protocol = targetUrl.protocol === 'ws:' ? 'wss:' : 'https:'
709-
}
710-
711-
if (userHttpConfig.hostname !== undefined && targetUrl.hostname === 'localhost') {
712-
targetUrl.hostname = userHttpConfig.hostname
713-
}
714-
715-
return targetUrl
716-
}
717-
718689
// TODO: sort proxies by descending prefix length.
719690

720691
// HTTP request proxy.
@@ -723,11 +694,11 @@ const setUpProxies = (express, opts, xo) => {
723694

724695
for (const prefix in opts) {
725696
if (url.startsWith(prefix)) {
726-
const target = getTargetUrl(opts[prefix], 'http:')
697+
const { targetUrl, isLocal } = getProxyTargetUrl(opts[prefix], 'http:', userHttpConfig)
727698

728699
proxy.web(req, res, {
729-
agent: target.hostname === 'localhost' ? undefined : xo.httpAgent,
730-
target: target.href + url.slice(prefix.length),
700+
agent: isLocal ? undefined : xo.httpAgent,
701+
target: targetUrl.href + url.slice(prefix.length),
731702
})
732703

733704
return
@@ -748,11 +719,11 @@ const setUpProxies = (express, opts, xo) => {
748719

749720
for (const prefix in opts) {
750721
if (url.startsWith(prefix)) {
751-
const target = getTargetUrl(opts[prefix], 'ws:')
722+
const { targetUrl, isLocal } = getProxyTargetUrl(opts[prefix], 'ws:', userHttpConfig)
752723

753724
proxy.ws(req, socket, head, {
754-
agent: new URL(target).hostname === 'localhost' ? undefined : xo.httpAgent,
755-
target: target.href + url.slice(prefix.length),
725+
agent: isLocal ? undefined : xo.httpAgent,
726+
target: targetUrl.href + url.slice(prefix.length),
756727
})
757728

758729
return

0 commit comments

Comments
 (0)