CVE-2023-46746 (TypeScript, published 2023-12-01T22:15Z)
Description: value: PostHog provides open-source product analytics, session recording, feature flagging and A/B testing that you can self-host. A server-side request forgery (SSRF), which can only be exploited by authenticated users, was found in Posthog. Posthog did not verify whether a URL was local when enabling webhooks, allowing authenticated users to forge a POST request. This vulnerability has been addressed in `22bd5942` and will be included in subsequent releases. There are no known workarounds for this vulnerability.
Severity: nan (CVSS v3 base score: 4.3)
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Repository: https://github.com/PostHog/posthog
Files changed:
plugin-server/bin/ci_functional_tests.sh
plugin-server/src/config/config.ts
plugin-server/src/main/ingestion-queues/on-event-handler-consumer.ts
plugin-server/src/types.ts
plugin-server/src/utils/db/hub.ts
plugin-server/src/utils/fetch.ts
plugin-server/src/worker/ingestion/hooks.ts
plugin-server/src/worker/vm/imports.ts
plugin-server/src/worker/vm/vm.ts
plugin-server/tests/worker/ingestion/event-pipeline/event-pipeline-integration.test.ts
plugin-server/tests/worker/ingestion/hooks.test.ts
posthog/api/user.py
Commit message: fix(webhooks): Always check for unsafe urls (#18360)

* fix(webhooks): Always check for unsafe urls

* Roll out the hostname guard fully

The mechanism is now only disabled in local dev.

* Update hooks.test.ts

* Disable URL check in functional tests

---------

Co-authored-by: Michael Matloka <michal@matloka.com>

Vulnerable code:
        FETCH_HOSTNAME_GUARD_TEAMS: '',
        new Set(serverConfig.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number)),
    FETCH_HOSTNAME_GUARD_TEAMS: string
    /** null means that the hostname guard is enabled for everyone */
    fetchHostnameGuardTeams: Set<number> | null
    const fetchHostnameGuardTeams =
        serverConfig.FETCH_HOSTNAME_GUARD_TEAMS === '*'
            ? null
            : new Set(serverConfig.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number))
        fetchHostnameGuardTeams,
    const request = new Request(url, init)
    return await runInSpan(
        {
            op: 'fetch',
            description: `${request.method} ${request.url}`,
        },
        async () => await fetch(url, init)
    )
}

trackedFetch.isRedirect = fetch.isRedirect
trackedFetch.FetchError = FetchError

export async function safeTrackedFetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
            await raiseIfUserProvidedUrlUnsafe(request.url)
safeTrackedFetch.isRedirect = fetch.isRedirect
safeTrackedFetch.FetchError = FetchError
import { isCloud } from '../../utils/env-utils'
import { safeTrackedFetch, trackedFetch } from '../../utils/fetch'
    /** null means that the hostname guard is enabled for everyone */
    fetchHostnameGuardTeams: Set<number> | null

        fetchHostnameGuardTeams: Set<number> | null = new Set(),
        this.fetchHostnameGuardTeams = fetchHostnameGuardTeams
        const relevantFetch =
            isCloud() && (!this.fetchHostnameGuardTeams || this.fetchHostnameGuardTeams.has(team.id))
                ? safeTrackedFetch
                : trackedFetch
                const request = await relevantFetch(webhookUrl, {
        const relevantFetch =
            isCloud() && (!this.fetchHostnameGuardTeams || this.fetchHostnameGuardTeams.has(hook.team_id))
                ? safeTrackedFetch
                : trackedFetch
            const request = await relevantFetch(hook.target, {
import { Hub } from 'types'
import { isCloud, isTestEnv } from '../../utils/env-utils'
import { safeTrackedFetch, trackedFetch } from '../../utils/fetch'
export function determineImports(hub: Hub, teamId: number) {
    return {
        ...(isTestEnv()
            ? {
                  'test-utils/write-to-file': writeToFile,
              }
            : {}),
        '@google-cloud/bigquery': bigquery,
        '@google-cloud/pubsub': pubsub,
        '@google-cloud/storage': gcs,
        '@posthog/plugin-contrib': contrib,
        '@posthog/plugin-scaffold': scaffold,
        'aws-sdk': AWS,
        ethers: ethers,
        'generic-pool': genericPool,
        'node-fetch':
            isCloud() && (!hub.fetchHostnameGuardTeams || hub.fetchHostnameGuardTeams.has(teamId))
                ? safeTrackedFetch
                : trackedFetch,
        'snowflake-sdk': snowflake,
        crypto: crypto,
        jsonwebtoken: jsonwebtoken,
        faker: faker,
        pg: pg,
        stream: { PassThrough },
        url: url,
        zlib: zlib,
    }
import { determineImports } from './imports'
    const imports = determineImports(hub, pluginConfig.team_id)

    const transformedCode = transformCode(indexJs, hub, imports)
    vm.freeze(imports['node-fetch'], 'fetch')
    vm.freeze(imports, '__pluginHostImports')
            new Set(hub.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number)),
import { isCloud } from '../../../src/utils/env-utils'
jest.mock('../../../src/utils/env-utils')

            jest.mocked(isCloud).mockReturnValue(false) // Disable private IP guard
                new Set([hook.team_id]), // Hostname guard enabled
            jest.mocked(isCloud).mockReturnValue(true) // Enable private IP guard, which example.com should pass

        test('private IP hook allowed on self-hosted', async () => {
            await hookCommander.postRestHook({ ...hook, target: 'http://127.0.0.1' }, { event: 'foo' } as any)

            expect(fetch).toHaveBeenCalledWith('http://127.0.0.1', expect.anything())
        })

        test('private IP hook forbidden on Cloud', async () => {
            jest.mocked(isCloud).mockReturnValue(true)
from posthog.cloud_utils import is_cloud
        if is_cloud():  # Protect against SSRF

Fixed code:
export NODE_ENV=production-functional-tests
import { isProdEnv } from './env-utils'
            if (isProdEnv() && !process.env.NODE_ENV?.includes('functional-tests')) {
                await raiseIfUserProvidedUrlUnsafe(request.url)
            }
trackedFetch.isRedirect = fetch.isRedirect
trackedFetch.FetchError = FetchError
import { trackedFetch } from '../../utils/fetch'
                const request = await trackedFetch(webhookUrl, {
            const request = await trackedFetch(hook.target, {
import { isTestEnv } from '../../utils/env-utils'
import { trackedFetch } from '../../utils/fetch'
export const AVAILABLE_IMPORTS = {
    ...(isTestEnv()
        ? {
              'test-utils/write-to-file': writeToFile,
          }
        : {}),
    '@google-cloud/bigquery': bigquery,
    '@google-cloud/pubsub': pubsub,
    '@google-cloud/storage': gcs,
    '@posthog/plugin-contrib': contrib,
    '@posthog/plugin-scaffold': scaffold,
    'aws-sdk': AWS,
    ethers: ethers,
    'generic-pool': genericPool,
    'node-fetch': trackedFetch,
    'snowflake-sdk': snowflake,
    crypto: crypto,
    jsonwebtoken: jsonwebtoken,
    faker: faker,
    pg: pg,
    stream: { PassThrough },
    url: url,
    zlib: zlib,
import { AVAILABLE_IMPORTS } from './imports'
    const transformedCode = transformCode(indexJs, hub, AVAILABLE_IMPORTS)
    vm.freeze(AVAILABLE_IMPORTS['node-fetch'], 'fetch')
    vm.freeze(AVAILABLE_IMPORTS, '__pluginHostImports')
    beforeEach(() => {
        process.env.NODE_ENV = 'test'
    })

        test('private IP hook forbidden in prod', async () => {
            process.env.NODE_ENV = 'production'
        if not settings.DEBUG: