Skip to content

Commit 5545654

Browse files
committed
fix: only trust forwarded IP header from configured trusted proxies
1 parent f5093cb commit 5545654

5 files changed

Lines changed: 113 additions & 8 deletions

File tree

CONFIGURATION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
126126
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
127127
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
128128
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
129+
| network.trustedProxies | Optional allow-list of proxy IPs allowed to set `network.remoteIpHeader`; otherwise socket remote IP is used. |
129130
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
130131
| nip05.domainWhitelist | List of domains allowed for NIP-05 verification. If set, only authors verified at these domains can publish. |
131132
| nip05.maxConsecutiveFailures | Number of consecutive verification failures before giving up on an author. Defaults to 20. |
@@ -144,5 +145,4 @@ The settings below are listed in alphabetical order by name. Please keep this ta
144145
| payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. |
145146
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
146147
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`. |
147-
| workers.count | Number of workers to spin up to handle incoming connections. |
148-
| | Spin workers as many CPUs are available when set to zero. Defaults to zero. |
148+
| workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. |

resources/default-settings.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ network:
5757
maxPayloadSize: 524288
5858
# Comment the next line if using CloudFlare proxy
5959
remoteIpHeader: x-forwarded-for
60+
# Optional: only trust forwarding headers from these proxy IPs
61+
trustedProxies: []
6062
# Uncomment the next line if using CloudFlare proxy
6163
# remoteIpHeader: cf-connecting-ip
6264
workers:

src/@types/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface Info {
1414
export interface Network {
1515
maxPayloadSize?: number
1616
remoteIpHeader?: string
17+
trustedProxies?: string[]
1718
}
1819

1920
export interface RateLimit {

src/utils/http.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@ import { IncomingMessage } from 'http'
22

33
import { Settings } from '../@types/settings'
44

5+
const normalizeIpAddress = (input: string): string => {
6+
if (input.startsWith('::ffff:')) {
7+
return input.slice(7)
8+
}
9+
10+
return input
11+
}
12+
13+
const isTrustedProxy = (ipAddress: string, settings: Settings): boolean => {
14+
const trustedProxies = settings.network?.trustedProxies
15+
16+
if (!Array.isArray(trustedProxies) || trustedProxies.length === 0) {
17+
return false
18+
}
19+
20+
const normalizedRemote = normalizeIpAddress(ipAddress)
21+
22+
return trustedProxies.some((trustedProxy) => {
23+
return normalizeIpAddress(trustedProxy) === normalizedRemote
24+
})
25+
}
26+
527
export const getRemoteAddress = (request: IncomingMessage, settings: Settings): string => {
628
let header: string | undefined
729
// TODO: Remove deprecation warning
@@ -13,7 +35,22 @@ export const getRemoteAddress = (request: IncomingMessage, settings: Settings):
1335
header = settings.network.remoteIpHeader as string
1436
}
1537

16-
const result = (request.headers[header] ?? request.socket.remoteAddress) as string
38+
const trustedProxies = settings.network?.trustedProxies
39+
if (header && (!Array.isArray(trustedProxies) || trustedProxies.length === 0)) {
40+
console.warn('WARNING: network.remoteIpHeader is set but network.trustedProxies is empty. Forwarded headers will be ignored. Add your proxy IP to network.trustedProxies.')
41+
}
42+
43+
const headerAddress = header
44+
? request.headers[header]
45+
: undefined
46+
const socketAddress = request.socket.remoteAddress
47+
48+
const trustedProxy = typeof socketAddress === 'string'
49+
&& isTrustedProxy(socketAddress, settings)
50+
51+
const result = trustedProxy && typeof headerAddress === 'string'
52+
? headerAddress
53+
: socketAddress
1754

18-
return result.split(',')[0]
55+
return (result as string).split(',')[0].trim()
1956
}

test/unit/utils/http.spec.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { expect } from 'chai'
22
import { IncomingMessage } from 'http'
3+
import sinon from 'sinon'
34

4-
import { getRemoteAddress } from '../../../src/utils/http'
5+
import { _resetWarnings, getRemoteAddress } from '../../../src/utils/http'
56

67
describe('getRemoteAddress', () => {
78
const header = 'x-forwarded-for'
@@ -22,14 +23,78 @@ describe('getRemoteAddress', () => {
2223
})
2324

2425
it('returns address using network.remote_ip_address when set', () => {
25-
expect(getRemoteAddress(request, { network: { remote_ip_header: header } } as any)).to.equal(address)
26+
expect(
27+
getRemoteAddress(
28+
request,
29+
{ network: { 'remote_ip_header': header, trustedProxies: [socketAddress] } } as any,
30+
)
31+
).to.equal(address)
2632
})
2733

2834
it('returns address using network.remoteIpAddress when set', () => {
29-
expect(getRemoteAddress(request, { network: { remoteIpHeader: header } } as any)).to.equal(address)
35+
expect(
36+
getRemoteAddress(
37+
request,
38+
{ network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any,
39+
)
40+
).to.equal(address)
41+
})
42+
43+
it('returns socket address when proxy is not trusted', () => {
44+
expect(
45+
getRemoteAddress(
46+
request,
47+
{ network: { remoteIpHeader: header, trustedProxies: ['1.1.1.1'] } } as any,
48+
)
49+
).to.equal(socketAddress)
50+
})
51+
52+
it('normalizes ipv4-mapped trusted proxy addresses', () => {
53+
expect(
54+
getRemoteAddress(
55+
{
56+
headers: {
57+
[header]: address,
58+
},
59+
socket: {
60+
remoteAddress: '::ffff:127.0.0.1',
61+
},
62+
} as any,
63+
{ network: { remoteIpHeader: header, trustedProxies: ['127.0.0.1'] } } as any,
64+
)
65+
).to.equal(address)
3066
})
3167

3268
it('returns address from socket when header is unset', () => {
33-
expect(getRemoteAddress(request, { network: {} } as any)).to.equal(socketAddress)
69+
expect(
70+
getRemoteAddress(
71+
request,
72+
{ network: { } } as any,
73+
)
74+
).to.equal(socketAddress)
75+
})
76+
77+
it('returns first address when forwarded header is an array', () => {
78+
const arrayRequest = {
79+
headers: { [header]: [address, 'other-address'] },
80+
socket: { remoteAddress: socketAddress },
81+
} as any
82+
expect(
83+
getRemoteAddress(
84+
arrayRequest,
85+
{ network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any,
86+
)
87+
).to.equal(address)
88+
})
89+
90+
it('emits empty trustedProxies warning only once', () => {
91+
_resetWarnings()
92+
const warn = sinon.stub(console, 'warn')
93+
const settings = { network: { remoteIpHeader: header, trustedProxies: [] } } as any
94+
getRemoteAddress(request, settings)
95+
getRemoteAddress(request, settings)
96+
const warningCalls = warn.args.filter(([msg]) => typeof msg === 'string' && msg.includes('trustedProxies is empty'))
97+
warn.restore()
98+
expect(warningCalls).to.have.lengthOf(1)
3499
})
35100
})

0 commit comments

Comments
 (0)