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/nip98-http-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

feat(http): build absolute request URL from relay_url
63 changes: 55 additions & 8 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}
100 changes: 76 additions & 24 deletions test/unit/utils/http.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
})

Expand All @@ -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', () => {
Expand All @@ -70,21 +65,21 @@ 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)
})
})

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')
})

Expand Down Expand Up @@ -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)
})
})
Loading