diff --git a/.changeset/tasty-parents-pump.md b/.changeset/tasty-parents-pump.md new file mode 100644 index 00000000..d7cc8504 --- /dev/null +++ b/.changeset/tasty-parents-pump.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix IP spoofing via unconditional trust of x-forwarded-for header diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 624b8cf4..7e898cd6 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | @@ -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. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 7f0ea0ef..d42326c5 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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: diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 8c36fe8a..67f28536 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -14,6 +14,7 @@ export interface Info { export interface Network { maxPayloadSize?: number remoteIpHeader?: string + trustedProxies?: string[] } export interface RateLimit { diff --git a/src/utils/http.ts b/src/utils/http.ts index 92dc945a..0903330a 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -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 @@ -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() } diff --git a/test/unit/utils/http.spec.ts b/test/unit/utils/http.spec.ts index f00c50a6..eddfa29a 100644 --- a/test/unit/utils/http.spec.ts +++ b/test/unit/utils/http.spec.ts @@ -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) }) })