CVE-2023-46746: Server-Side Request Forgery (SSRF) (CWE-918)
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.
CWE description: 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.
CVSS v3 base score: 4.3
Published: 2023-12-01T22:15Z
Repository: https://github.com/PostHog/posthog
Language: TypeScript
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 date: 2023-11-03 12:58:30 +0100
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>
Diff: diff --git a/plugin-server/bin/ci_functional_tests.sh b/plugin-server/bin/ci_functional_tests.sh
index e734bd29f..9eff572c7 100755
--- a/plugin-server/bin/ci_functional_tests.sh
+++ b/plugin-server/bin/ci_functional_tests.sh
@@ -14,10 +14,11 @@ set -e -o pipefail
 export WORKER_CONCURRENCY=1
 export CONVERSION_BUFFER_ENABLED=true
 export BUFFER_CONVERSION_SECONDS=2 # Make sure we don't have to wait for the default 60 seconds
 export KAFKA_MAX_MESSAGE_BATCH_SIZE=0
 export APP_METRICS_GATHERED_FOR_ALL=true
+export NODE_ENV=production-functional-tests
 
 # Not important at all, but I like to see nice red/green for tests
 export FORCE_COLOR=true
 
 LOG_FILE=$(mktemp)
diff --git a/plugin-server/src/config/config.ts b/plugin-server/src/config/config.ts
index 497976954..5d08afb64 100644
--- a/plugin-server/src/config/config.ts
+++ b/plugin-server/src/config/config.ts
@@ -108,11 +108,10 @@ export function getDefaultConfig(): PluginsServerConfig {
         CLICKHOUSE_JSON_EVENTS_KAFKA_TOPIC: KAFKA_EVENTS_JSON,
         CONVERSION_BUFFER_ENABLED: false,
         CONVERSION_BUFFER_ENABLED_TEAMS: '',
         CONVERSION_BUFFER_TOPIC_ENABLED_TEAMS: '',
         BUFFER_CONVERSION_SECONDS: isDevEnv() ? 2 : 60, // KEEP IN SYNC WITH posthog/settings/ingestion.py
-        FETCH_HOSTNAME_GUARD_TEAMS: '',
         PERSON_INFO_CACHE_TTL: 5 * 60, // 5 min
         KAFKA_HEALTHCHECK_SECONDS: 20,
         OBJECT_STORAGE_ENABLED: true,
         OBJECT_STORAGE_ENDPOINT: 'http://localhost:19000',
         OBJECT_STORAGE_REGION: 'us-east-1',
diff --git a/plugin-server/src/main/ingestion-queues/on-event-handler-consumer.ts b/plugin-server/src/main/ingestion-queues/on-event-handler-consumer.ts
index 44a9e8ee6..096c0b5b5 100644
--- a/plugin-server/src/main/ingestion-queues/on-event-handler-consumer.ts
+++ b/plugin-server/src/main/ingestion-queues/on-event-handler-consumer.ts
@@ -87,11 +87,10 @@ export const startAsyncWebhooksHandlerConsumer = async ({
     const actionMatcher = new ActionMatcher(postgres, actionManager, statsd)
     const hookCannon = new HookCommander(
         postgres,
         teamManager,
         organizationManager,
-        new Set(serverConfig.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number)),
         appMetrics,
         statsd,
         serverConfig.EXTERNAL_REQUEST_TIMEOUT_MS
     )
     const concurrency = serverConfig.TASKS_PER_WORKER || 20
diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts
index 77bc3f76a..e295f5414 100644
--- a/plugin-server/src/types.ts
+++ b/plugin-server/src/types.ts
@@ -176,11 +176,10 @@ export interface PluginsServerConfig {
     RECORDING_PARTITIONS_CONSUMED_CONCURRENTLY: number
     CONVERSION_BUFFER_ENABLED: boolean
     CONVERSION_BUFFER_ENABLED_TEAMS: string
     CONVERSION_BUFFER_TOPIC_ENABLED_TEAMS: string
     BUFFER_CONVERSION_SECONDS: number
-    FETCH_HOSTNAME_GUARD_TEAMS: string
     PERSON_INFO_CACHE_TTL: number
     KAFKA_HEALTHCHECK_SECONDS: number
     OBJECT_STORAGE_ENABLED: boolean // Disables or enables the use of object storage. It will become mandatory to use object storage
     OBJECT_STORAGE_REGION: string // s3 region
     OBJECT_STORAGE_ENDPOINT: string // s3 endpoint
@@ -271,12 +270,10 @@ export interface Hub extends PluginsServerConfig {
     // diagnostics
     lastActivity: number
     lastActivityType: string
     statelessVms: StatelessVmMap
     conversionBufferEnabledTeams: Set<number>
-    /** null means that the hostname guard is enabled for everyone */
-    fetchHostnameGuardTeams: Set<number> | null
     // functions
     enqueuePluginJob: (job: EnqueuedPluginJob) => Promise<void>
     // ValueMatchers used for various opt-in/out features
     pluginConfigsToSkipElementsParsing: ValueMatcher<number>
     poeEmbraceJoinForTeams: ValueMatcher<number>
diff --git a/plugin-server/src/utils/db/hub.ts b/plugin-server/src/utils/db/hub.ts
index 79d6c56da..2d5c780b7 100644
--- a/plugin-server/src/utils/db/hub.ts
+++ b/plugin-server/src/utils/db/hub.ts
@@ -87,14 +87,10 @@ export async function createHub(
     const instanceId = new UUIDT()
 
     const conversionBufferEnabledTeams = new Set(
         serverConfig.CONVERSION_BUFFER_ENABLED_TEAMS.split(',').filter(String).map(Number)
     )
-    const fetchHostnameGuardTeams =
-        serverConfig.FETCH_HOSTNAME_GUARD_TEAMS === '*'
-            ? null
-            : new Set(serverConfig.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number))
 
     const statsd: StatsD | undefined = createStatsdClient(serverConfig, threadId)
 
     status.info('🤔', `Connecting to ClickHouse...`)
     const clickhouse = new ClickHouse({
@@ -191,11 +187,10 @@ export async function createHub(
         organizationManager,
         pluginsApiKeyManager,
         rootAccessManager,
         promiseManager,
         conversionBufferEnabledTeams,
-        fetchHostnameGuardTeams,
         pluginConfigsToSkipElementsParsing: buildIntegerMatcher(process.env.SKIP_ELEMENTS_PARSING_PLUGINS, true),
         poeEmbraceJoinForTeams: buildIntegerMatcher(process.env.POE_EMBRACE_JOIN_FOR_TEAMS, true),
         eventsToDropByToken: createEventsToDropByToken(process.env.DROP_EVENTS_BY_TOKEN_DISTINCT_ID),
     }
 
diff --git a/plugin-server/src/utils/fetch.ts b/plugin-server/src/utils/fetch.ts
index 298e9e70d..62da5b046 100644
--- a/plugin-server/src/utils/fetch.ts
+++ b/plugin-server/src/utils/fetch.ts
@@ -5,41 +5,30 @@ import dns from 'dns/promises'
 import * as ipaddr from 'ipaddr.js'
 import fetch, { type RequestInfo, type RequestInit, type Response, FetchError, Request } from 'node-fetch'
 import { URL } from 'url'
 
 import { runInSpan } from '../sentry'
+import { isProdEnv } from './env-utils'
 
 export async function trackedFetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
-    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> {
     const request = new Request(url, init)
     return await runInSpan(
         {
             op: 'fetch',
             description: `${request.method} ${request.url}`,
         },
         async () => {
-            await raiseIfUserProvidedUrlUnsafe(request.url)
+            if (isProdEnv() && !process.env.NODE_ENV?.includes('functional-tests')) {
+                await raiseIfUserProvidedUrlUnsafe(request.url)
+            }
             return await fetch(url, init)
         }
     )
 }
 
-safeTrackedFetch.isRedirect = fetch.isRedirect
-safeTrackedFetch.FetchError = FetchError
+trackedFetch.isRedirect = fetch.isRedirect
+trackedFetch.FetchError = FetchError
 
 /**
  * Raise if the provided URL seems unsafe, otherwise do nothing.
  *
  * Equivalent of Django raise_if_user_provided_url_unsafe.
diff --git a/plugin-server/src/worker/ingestion/hooks.ts b/plugin-server/src/worker/ingestion/hooks.ts
index 3057f3c76..55417b959 100644
--- a/plugin-server/src/worker/ingestion/hooks.ts
+++ b/plugin-server/src/worker/ingestion/hooks.ts
@@ -3,12 +3,11 @@ import { StatsD } from 'hot-shots'
 import { Histogram } from 'prom-client'
 import { format } from 'util'
 
 import { Action, Hook, PostIngestionEvent, Team } from '../../types'
 import { PostgresRouter, PostgresUse } from '../../utils/db/postgres'
-import { isCloud } from '../../utils/env-utils'
-import { safeTrackedFetch, trackedFetch } from '../../utils/fetch'
+import { trackedFetch } from '../../utils/fetch'
 import { status } from '../../utils/status'
 import { getPropertyValueByPath, stringify } from '../../utils/utils'
 import { AppMetrics } from './app-metrics'
 import { OrganizationManager } from './organization-manager'
 import { TeamManager } from './team-manager'
@@ -257,29 +256,24 @@ export class HookCommander {
     teamManager: TeamManager
     organizationManager: OrganizationManager
     appMetrics: AppMetrics
     statsd: StatsD | undefined
     siteUrl: string
-    /** null means that the hostname guard is enabled for everyone */
-    fetchHostnameGuardTeams: Set<number> | null
-
     /** Hook request timeout in ms. */
     EXTERNAL_REQUEST_TIMEOUT: number
 
     constructor(
         postgres: PostgresRouter,
         teamManager: TeamManager,
         organizationManager: OrganizationManager,
-        fetchHostnameGuardTeams: Set<number> | null = new Set(),
         appMetrics: AppMetrics,
         statsd: StatsD | undefined,
         timeout: number
     ) {
         this.postgres = postgres
         this.teamManager = teamManager
         this.organizationManager = organizationManager
-        this.fetchHostnameGuardTeams = fetchHostnameGuardTeams
         if (process.env.SITE_URL) {
             this.siteUrl = process.env.SITE_URL
         } else {
             status.warn('⚠️', 'SITE_URL env is not set for webhooks')
             this.siteUrl = ''
@@ -371,17 +365,13 @@ export class HookCommander {
                 `Posting Webhook slow. Timeout warning after ${
                     slowWarningTimeout / 1000
                 } sec! url=${webhookUrl} team_id=${team.id} event_id=${event.eventUuid}`
             )
         }, slowWarningTimeout)
-        const relevantFetch =
-            isCloud() && (!this.fetchHostnameGuardTeams || this.fetchHostnameGuardTeams.has(team.id))
-                ? safeTrackedFetch
-                : trackedFetch
         try {
             await instrumentWebhookStep('fetch', async () => {
-                const request = await relevantFetch(webhookUrl, {
+                const request = await trackedFetch(webhookUrl, {
                     method: 'POST',
                     body: JSON.stringify(message, undefined, 4),
                     headers: { 'Content-Type': 'application/json' },
                     timeout: this.EXTERNAL_REQUEST_TIMEOUT,
                 })
@@ -453,16 +443,12 @@ export class HookCommander {
                 `Posting RestHook slow. Timeout warning after ${slowWarningTimeout / 1000} sec! url=${
                     hook.target
                 } team_id=${event.teamId} event_id=${event.eventUuid}`
             )
         }, slowWarningTimeout)
-        const relevantFetch =
-            isCloud() && (!this.fetchHostnameGuardTeams || this.fetchHostnameGuardTeams.has(hook.team_id))
-                ? safeTrackedFetch
-                : trackedFetch
         try {
-            const request = await relevantFetch(hook.target, {
+            const request = await trackedFetch(hook.target, {
                 method: 'POST',
                 body: JSON.stringify(payload, undefined, 4),
                 headers: { 'Content-Type': 'application/json' },
                 timeout: this.EXTERNAL_REQUEST_TIMEOUT,
             })
diff --git a/plugin-server/src/worker/vm/imports.ts b/plugin-server/src/worker/vm/imports.ts
index 0b7e5e0df..98b168b79 100644
--- a/plugin-server/src/worker/vm/imports.ts
+++ b/plugin-server/src/worker/vm/imports.ts
@@ -10,42 +10,36 @@ import * as faker from 'faker'
 import * as genericPool from 'generic-pool'
 import * as jsonwebtoken from 'jsonwebtoken'
 import * as pg from 'pg'
 import snowflake from 'snowflake-sdk'
 import { PassThrough } from 'stream'
-import { Hub } from 'types'
 import * as url from 'url'
 import * as zlib from 'zlib'
 
-import { isCloud, isTestEnv } from '../../utils/env-utils'
-import { safeTrackedFetch, trackedFetch } from '../../utils/fetch'
+import { isTestEnv } from '../../utils/env-utils'
+import { trackedFetch } from '../../utils/fetch'
 import { writeToFile } from './extensions/test-utils'
 
-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,
-    }
+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,
 }
diff --git a/plugin-server/src/worker/vm/vm.ts b/plugin-server/src/worker/vm/vm.ts
index 95e40ca4a..eb6141942 100644
--- a/plugin-server/src/worker/vm/vm.ts
+++ b/plugin-server/src/worker/vm/vm.ts
@@ -9,11 +9,11 @@ import { createGeoIp } from './extensions/geoip'
 import { createGoogle } from './extensions/google'
 import { createJobs } from './extensions/jobs'
 import { createPosthog } from './extensions/posthog'
 import { createStorage } from './extensions/storage'
 import { createUtils } from './extensions/utilities'
-import { determineImports } from './imports'
+import { AVAILABLE_IMPORTS } from './imports'
 import { transformCode } from './transforms'
 import { upgradeExportEvents } from './upgrades/export-events'
 import { addHistoricalEventsExportCapability } from './upgrades/historical-export/export-historical-events'
 import { addHistoricalEventsExportCapabilityV2 } from './upgrades/historical-export/export-historical-events-v2'
 
@@ -32,23 +32,21 @@ export class TimeoutError extends RetryError {
 export function createPluginConfigVM(
     hub: Hub,
     pluginConfig: PluginConfig, // NB! might have team_id = 0
     indexJs: string
 ): PluginConfigVMResponse {
-    const imports = determineImports(hub, pluginConfig.team_id)
-
     const timer = new Date()
 
     const statsdTiming = (metric: string) => {
         hub.statsd?.timing(metric, timer, {
             pluginConfigId: String(pluginConfig.id),
             pluginName: String(pluginConfig.plugin?.name),
             teamId: String(pluginConfig.team_id),
         })
     }
 
-    const transformedCode = transformCode(indexJs, hub, imports)
+    const transformedCode = transformCode(indexJs, hub, AVAILABLE_IMPORTS)
 
     // Create virtual machine
     const vm = new VM({
         timeout: hub.TASK_TIMEOUT * 1000 + 1,
         sandbox: {},
@@ -57,14 +55,14 @@ export function createPluginConfigVM(
     // Add PostHog utilities to virtual machine
     vm.freeze(createConsole(hub, pluginConfig), 'console')
     vm.freeze(createPosthog(hub, pluginConfig), 'posthog')
 
     // Add non-PostHog utilities to virtual machine
-    vm.freeze(imports['node-fetch'], 'fetch')
+    vm.freeze(AVAILABLE_IMPORTS['node-fetch'], 'fetch')
     vm.freeze(createGoogle(), 'google')
 
-    vm.freeze(imports, '__pluginHostImports')
+    vm.freeze(AVAILABLE_IMPORTS, '__pluginHostImports')
 
     if (process.env.NODE_ENV === 'test') {
         vm.freeze(setTimeout, '__jestSetTimeout')
     }
 
diff --git a/plugin-server/tests/worker/ingestion/event-pipeline/event-pipeline-integration.test.ts b/plugin-server/tests/worker/ingestion/event-pipeline/event-pipeline-integration.test.ts
index 89bd8d8c2..c4de10099 100644
--- a/plugin-server/tests/worker/ingestion/event-pipeline/event-pipeline-integration.test.ts
+++ b/plugin-server/tests/worker/ingestion/event-pipeline/event-pipeline-integration.test.ts
@@ -50,11 +50,10 @@ describe('Event Pipeline integration test', () => {
         actionMatcher = new ActionMatcher(hub.db.postgres, actionManager)
         hookCannon = new HookCommander(
             hub.db.postgres,
             hub.teamManager,
             hub.organizationManager,
-            new Set(hub.FETCH_HOSTNAME_GUARD_TEAMS.split(',').filter(String).map(Number)),
             hub.appMetrics,
             undefined,
             hub.EXTERNAL_REQUEST_TIMEOUT_MS
         )
 
diff --git a/plugin-server/tests/worker/ingestion/hooks.test.ts b/plugin-server/tests/worker/ingestion/hooks.test.ts
index 47f70a8b0..09e2089b0 100644
--- a/plugin-server/tests/worker/ingestion/hooks.test.ts
+++ b/plugin-server/tests/worker/ingestion/hooks.test.ts
@@ -1,10 +1,9 @@
 import { DateTime } from 'luxon'
 import fetch, { FetchError } from 'node-fetch'
 
 import { Action, PostIngestionEvent, Team } from '../../../src/types'
-import { isCloud } from '../../../src/utils/env-utils'
 import { UUIDT } from '../../../src/utils/utils'
 import { AppMetrics } from '../../../src/worker/ingestion/app-metrics'
 import {
     determineWebhookType,
     getActionDetails,
@@ -15,13 +14,15 @@ import {
     HookCommander,
     WebhookType,
 } from '../../../src/worker/ingestion/hooks'
 import { Hook } from './../../../src/types'
 
-jest.mock('../../../src/utils/env-utils')
-
 describe('hooks', () => {
+    beforeEach(() => {
+        process.env.NODE_ENV = 'test'
+    })
+
     describe('determineWebhookType', () => {
         test('Slack', () => {
             const webhookType = determineWebhookType('https://hooks.slack.com/services/')
 
             expect(webhookType).toBe(WebhookType.Slack)
@@ -473,11 +474,10 @@ describe('hooks', () => {
     describe('postRestHook', () => {
         let hookCommander: HookCommander
         let hook: Hook
 
         beforeEach(() => {
-            jest.mocked(isCloud).mockReturnValue(false) // Disable private IP guard
             hook = {
                 id: 'id',
                 team_id: 1,
                 user_id: 1,
                 resource_id: 1,
@@ -488,11 +488,10 @@ describe('hooks', () => {
             }
             hookCommander = new HookCommander(
                 {} as any,
                 {} as any,
                 {} as any,
-                new Set([hook.team_id]), // Hostname guard enabled
                 { queueError: () => Promise.resolve(), queueMetric: () => Promise.resolve() } as unknown as AppMetrics,
                 undefined,
                 20000
             )
         })
@@ -521,12 +520,10 @@ describe('hooks', () => {
                 timeout: 20000,
             })
         })
 
         test('person data from the event', async () => {
-            jest.mocked(isCloud).mockReturnValue(true) // Enable private IP guard, which example.com should pass
-
             const now = new Date().toISOString()
             const uuid = new UUIDT().toString()
             await hookCommander.postRestHook(hook, {
                 event: 'foo',
                 teamId: hook.team_id,
@@ -559,18 +556,12 @@ describe('hooks', () => {
                 method: 'POST',
                 timeout: 20000,
             })
         })
 
-        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)
+        test('private IP hook forbidden in prod', async () => {
+            process.env.NODE_ENV = 'production'
 
             await expect(
                 hookCommander.postRestHook({ ...hook, target: 'http://127.0.0.1' }, { event: 'foo' } as any)
             ).rejects.toThrow(new FetchError('Internal hostname', 'posthog-host-guard'))
         })
diff --git a/posthog/api/user.py b/posthog/api/user.py
index 541a42807..5aea73715 100644
--- a/posthog/api/user.py
+++ b/posthog/api/user.py
@@ -31,11 +31,10 @@ from posthog.api.decide import hostname_in_allowed_url_list
 from posthog.api.email_verification import EmailVerifier
 from posthog.api.organization import OrganizationSerializer
 from posthog.api.shared import OrganizationBasicSerializer, TeamBasicSerializer
 from posthog.api.utils import raise_if_user_provided_url_unsafe
 from posthog.auth import authenticate_secondarily
-from posthog.cloud_utils import is_cloud
 from posthog.email import is_email_available
 from posthog.event_usage import (
     report_user_logged_in,
     report_user_updated,
     report_user_verified_email,
@@ -468,11 +467,11 @@ def test_slack_webhook(request):
 
     if not webhook:
         return JsonResponse({"error": "no webhook URL"})
     message = {"text": "_Greetings_ from PostHog!"}
     try:
-        if is_cloud():  # Protect against SSRF
+        if not settings.DEBUG:
             raise_if_user_provided_url_unsafe(webhook)
         response = requests.post(webhook, verify=False, json=message)
 
         if response.ok:
             return JsonResponse({"success": True})

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: