diff --git a/.changeset/nip98-http-url.md b/.changeset/nip98-http-url.md new file mode 100644 index 00000000..87a5121b --- /dev/null +++ b/.changeset/nip98-http-url.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +feat(http): build absolute request URL from relay_url diff --git a/src/utils/http.ts b/src/utils/http.ts index 47d18da0..9dd937f9 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -1,7 +1,6 @@ import { IncomingMessage } from 'http' - -import { createLogger } from '../factories/logger-factory' import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' const logger = createLogger('http-utils') @@ -45,19 +44,18 @@ export const getRemoteAddress = (request: IncomingMessage, settings: Settings): 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.') + 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 trustedProxy = typeof socketAddress === 'string' && isTrustedProxy(socketAddress, settings) - const result = trustedProxy && typeof headerAddress === 'string' - ? headerAddress - : socketAddress + const result = trustedProxy && typeof headerAddress === 'string' ? headerAddress : socketAddress return (result as string).split(',')[0].trim() } @@ -134,3 +132,52 @@ export const joinPathPrefix = (prefix: string, path: string): string => { return `${normalizedPrefix}${normalizedPath}` } + +/** + * Absolute URL for NIP-98 `u` matching (scheme + host + path + query). + * Scheme and host come only from `info.relay_url` (never request Host / forwarded proto). + * Returns undefined if relay_url is missing or not a usable http(s)/ws(s) URL. + */ +export const getAbsoluteHttpRequestUrl = ( + request: IncomingMessage & { originalUrl?: string }, + settings: Settings, +): string | undefined => { + const origin = getPublicHttpOrigin(settings) + if (!origin) { + return undefined + } + + const originalUrl = typeof request.originalUrl === 'string' ? request.originalUrl : '/' + const prefix = getPublicPathPrefix(request, settings) + const pathAndQuery = + !prefix || originalUrl === prefix || originalUrl.startsWith(`${prefix}/`) || originalUrl.startsWith(`${prefix}?`) + ? originalUrl + : joinPathPrefix(prefix, originalUrl) + + return `${origin}${pathAndQuery}` +} + +const getPublicHttpOrigin = (settings: Settings): string | undefined => { + try { + const relayUrl = settings.info?.relay_url + if (typeof relayUrl !== 'string' || relayUrl.length === 0) { + return undefined + } + + const parsed = new URL(relayUrl) + if (parsed.host.length === 0) { + return undefined + } + + if (parsed.protocol === 'wss:' || parsed.protocol === 'https:') { + return `https://${parsed.host}` + } + if (parsed.protocol === 'ws:' || parsed.protocol === 'http:') { + return `http://${parsed.host}` + } + } catch { + // fall through + } + + return undefined +} diff --git a/test/unit/utils/http.spec.ts b/test/unit/utils/http.spec.ts index 39899f98..f9454d95 100644 --- a/test/unit/utils/http.spec.ts +++ b/test/unit/utils/http.spec.ts @@ -1,7 +1,13 @@ import { expect } from 'chai' import { IncomingMessage } from 'http' -import { getPublicPathPrefix, getRemoteAddress, isSecureRequest, joinPathPrefix } from '../../../src/utils/http' +import { + getAbsoluteHttpRequestUrl, + getPublicPathPrefix, + getRemoteAddress, + isSecureRequest, + joinPathPrefix, +} from '../../../src/utils/http' describe('getRemoteAddress', () => { const header = 'x-forwarded-for' @@ -23,19 +29,13 @@ describe('getRemoteAddress', () => { it('returns address using network.remoteIpHeader when set', () => { expect( - getRemoteAddress( - request, - { network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any, - ) + 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, - ) + getRemoteAddress(request, { network: { remoteIpHeader: header, trustedProxies: ['1.1.1.1'] } } as any), ).to.equal(socketAddress) }) @@ -51,17 +51,12 @@ describe('getRemoteAddress', () => { }, } 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', () => { @@ -70,10 +65,7 @@ describe('getRemoteAddress', () => { socket: { remoteAddress: socketAddress }, } as any expect( - getRemoteAddress( - arrayRequest, - { network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any, - ) + getRemoteAddress(arrayRequest, { network: { remoteIpHeader: header, trustedProxies: [socketAddress] } } as any), ).to.equal(address) }) }) @@ -81,10 +73,13 @@ describe('getRemoteAddress', () => { describe('getPublicPathPrefix', () => { it('returns the relay_url path prefix by default', () => { expect( - getPublicPathPrefix({ headers: {}, socket: { remoteAddress: 'client' } } as any, { - info: { relay_url: 'wss://relay.example.com/nostream/' }, - network: {}, - } as any), + getPublicPathPrefix( + { headers: {}, socket: { remoteAddress: 'client' } } as any, + { + info: { relay_url: 'wss://relay.example.com/nostream/' }, + network: {}, + } as any, + ), ).to.equal('/nostream') }) @@ -216,3 +211,60 @@ describe('isSecureRequest', () => { ).to.equal(false) }) }) + +describe('getAbsoluteHttpRequestUrl', () => { + const request = { + originalUrl: '/admin/settings', + get: (name: string) => (name.toLowerCase() === 'host' ? 'evil.example' : undefined), + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'x-forwarded-proto': 'https', host: 'evil.example' }, + } as any + + it('binds scheme and host from relay_url, ignoring request Host and forwarded proto', () => { + expect( + getAbsoluteHttpRequestUrl(request, { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any), + ).to.equal('https://relay.example.com/nostream/admin/settings') + }) + + it('maps ws relay_url to http', () => { + expect( + getAbsoluteHttpRequestUrl(request, { + info: { relay_url: 'ws://relay.example.com:8080' }, + network: {}, + } as any), + ).to.equal('http://relay.example.com:8080/admin/settings') + }) + + it('does not double-apply prefix when originalUrl already includes it', () => { + expect( + getAbsoluteHttpRequestUrl( + { ...request, originalUrl: '/nostream/admin/settings' }, + { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: {}, + } as any, + ), + ).to.equal('https://relay.example.com/nostream/admin/settings') + }) + + it('returns undefined when relay_url is missing so Host cannot be trusted', () => { + expect( + getAbsoluteHttpRequestUrl(request, { + info: {}, + network: {}, + } as any), + ).to.equal(undefined) + }) + + it('returns undefined for non http(s)/ws(s) relay_url protocols', () => { + expect( + getAbsoluteHttpRequestUrl(request, { + info: { relay_url: 'ftp://relay.example.com' }, + network: {}, + } as any), + ).to.equal(undefined) + }) +})