Skip to content
Merged
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 .changeset/tasty-parents-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

Fix IP spoofing via unconditional trust of x-forwarded-for header
35 changes: 30 additions & 5 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,12 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. |
| limits.event.createdAt.minNegativeDelta | Maximum number of secodns an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. |
| | Defaults to zero. Disabled when set to zero. |
| limits.event.createdAt.minNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. |
| limits.event.kind.blacklist | List of event kinds to always reject. Leave empty to allow any. |
| limits.event.kind.whitelist | List of event kinds to always allow. Leave empty to allow any. |
| limits.event.pubkey.blacklist | List of public keys to always reject. Public keys in this list will not be able to post to this relay. |
| limits.event.pubkey.minLeadingZeroBits | Leading zero bits required on the public key of incoming events for proof of work. |
| | Defaults to zero. Disabled when set to zero. |
| limits.event.pubkey.minLeadingZeroBits | Leading zero bits required on the public key of incoming events for proof of work. Defaults to zero. Disabled when set to zero. |
| limits.event.pubkey.whitelist | List of public keys to always allow. Only public keys in this list will be able to post to this relay. Use for private relays. |
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window during which requests are counted. For `ewma`: the half-life of the exponential decay — shorter values forget bursts faster, longer values are stricter on bursty clients. |
Expand Down Expand Up @@ -184,3 +182,30 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. |
| limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
| limits.rateLimiter.strategy | Rate limiting strategy. Either `ewma` or `sliding_window`. Defaults to `ewma`. When using `ewma`, the `period` field in each rate limit serves as the half-life for the exponential decay function. Note: when switching from `sliding_window` to `ewma`, consider increasing `rate` values slightly as EWMA penalizes bursty behavior more aggressively. |
| mirroring.static[].address | Address of mirrored relay. (e.g. ws://100.100.100.100:8008) |
| mirroring.static[].filters | Subscription filters used to mirror. |
| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. |
| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. |
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
| network.trustedProxies | Optional allow-list of proxy IPs allowed to set `network.remoteIpHeader`; otherwise socket remote IP is used. |
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
| nip05.domainWhitelist | List of domains allowed for NIP-05 verification. If set, only authors verified at these domains can publish. |
| nip05.maxConsecutiveFailures | Number of consecutive verification failures before giving up on an author. Defaults to 20. |
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
| paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) |
| paymentProcessors.zebedee.baseURL | Zebedee's API base URL. |
| paymentProcessors.zebedee.callbackBaseURL | Public-facing Nostream's Zebedee Callback URL (e.g. https://relay.your-domain.com/callbacks/zebedee) |
| paymentProcessors.zebedee.ipWhitelist | List with Zebedee's API Production IPs. See [ZBD API Documentation](https://api-reference.zebedee.io/#c7e18276-6935-4cca-89ae-ad949efe9a6a) for more info. |
| payments.enabled | Enabled payments. Defaults to false. |
| payments.feeSchedules.admission[].amount | Admission fee amount in msats. |
| payments.feeSchedules.admission[].enabled | Enables admission fee. Defaults to false. |
| payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. |
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`. |
| 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. |
12 changes: 9 additions & 3 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,16 @@ nip05:
domainBlacklist: []
network:
maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy
remoteIpHeader: x-forwarded-for
# Uncomment the next line if using CloudFlare proxy
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.
# remoteIpHeader: x-forwarded-for
# remoteIpHeader: cf-connecting-ip
# Proxy IPs allowed to set remoteIpHeader (loopback and common docker internal)
trustedProxies:
- "127.0.0.1"
- "::ffff:127.0.0.1"
- "::1"
- "10.10.10.1"
- "::ffff:10.10.10.1"
workers:
count: 0
mirroring:
Expand Down
1 change: 1 addition & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface Info {
export interface Network {
maxPayloadSize?: number
remoteIpHeader?: string
trustedProxies?: string[]
}

export interface RateLimit {
Expand Down
40 changes: 38 additions & 2 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ import { Settings } from '../@types/settings'

const logger = createLogger('http-utils')

const normalizeIpAddress = (input: string): string => {
if (input.startsWith('::ffff:')) {
return input.slice(7)
}

return input
}

const isTrustedProxy = (ipAddress: string, settings: Settings): boolean => {
const trustedProxies = settings.network?.trustedProxies

if (!Array.isArray(trustedProxies) || trustedProxies.length === 0) {
return false
}

const normalizedRemote = normalizeIpAddress(ipAddress)

return trustedProxies.some((trustedProxy) => {
return normalizeIpAddress(trustedProxy) === normalizedRemote
})
}

export const getRemoteAddress = (request: IncomingMessage, settings: Settings): string => {
let header: string | undefined
// TODO: Remove deprecation warning
Expand All @@ -16,7 +38,21 @@ export const getRemoteAddress = (request: IncomingMessage, settings: Settings):
header = settings.network.remoteIpHeader as string
}

const result = (request.headers[header] ?? request.socket.remoteAddress) as string
const trustedProxies = settings.network?.trustedProxies
if (header && (!Array.isArray(trustedProxies) || trustedProxies.length === 0)) {
logger.warn('WARNING: network.remoteIpHeader is set but network.trustedProxies is empty. Forwarded headers will be ignored. Add your proxy IP to network.trustedProxies.')
}

const rawHeaderAddress = header ? request.headers[header] : undefined
const headerAddress = Array.isArray(rawHeaderAddress) ? rawHeaderAddress[0] : rawHeaderAddress
const socketAddress = request.socket.remoteAddress

const trustedProxy = typeof socketAddress === 'string'
&& isTrustedProxy(socketAddress, settings)

const result = trustedProxy && typeof headerAddress === 'string'
? headerAddress
: socketAddress

return result.split(',')[0]
return (result as string).split(',')[0].trim()
}
59 changes: 56 additions & 3 deletions test/unit/utils/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,67 @@ describe('getRemoteAddress', () => {
})

it('returns address using network.remote_ip_address when set', () => {
expect(getRemoteAddress(request, { network: { remote_ip_header: header } } as any)).to.equal(address)
expect(
getRemoteAddress(
request,
{ network: { 'remote_ip_header': header, trustedProxies: [socketAddress] } } as any,
)
).to.equal(address)
})

it('returns address using network.remoteIpAddress when set', () => {
expect(getRemoteAddress(request, { network: { remoteIpHeader: header } } as any)).to.equal(address)
expect(
getRemoteAddress(
request,
{ network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any,
)
).to.equal(address)
})

it('returns socket address when proxy is not trusted', () => {
expect(
getRemoteAddress(
request,
{ network: { remoteIpHeader: header, trustedProxies: ['1.1.1.1'] } } as any,
)
).to.equal(socketAddress)
})

it('normalizes ipv4-mapped trusted proxy addresses', () => {
expect(
getRemoteAddress(
{
headers: {
[header]: address,
},
socket: {
remoteAddress: '::ffff:127.0.0.1',
},
} as any,
{ network: { remoteIpHeader: header, trustedProxies: ['127.0.0.1'] } } as any,
)
).to.equal(address)
})

it('returns address from socket when header is unset', () => {
expect(getRemoteAddress(request, { network: {} } as any)).to.equal(socketAddress)
expect(
getRemoteAddress(
request,
{ network: { } } as any,
)
).to.equal(socketAddress)
})

it('returns first address when forwarded header is an array', () => {
const arrayRequest = {
headers: { [header]: [address, 'other-address'] },
socket: { remoteAddress: socketAddress },
} as any
expect(
getRemoteAddress(
arrayRequest,
{ network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any,
)
).to.equal(address)
})
})
Loading