TryGhost / TryGhost/Ghost

Webhooks cannot target explicitly trusted private services in Production

Open
#30,893 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs:triage
Dominant language
TypeScript
Stars
55.4k
Forks
12k
Avg merge
1d 41m
Merged PRs (30d)
534

Description

Issue Summary

Self-hosted Ghost instances running in production cannot deliver webhooks to explicitly trusted services on the same private Docker network.

Ghost rejects webhook targets whose hostnames resolve to private IP addresses:
URL_PRIVATE_INVALID: URL resolves to a non-permitted private IP block

Please consider supporting an configuration controlled allowlist for exact private webhook hostnames and ports.

Steps to Reproduce
  1. Create a private Docker network:
docker network create ghost-network
  1. Start a MySQL 8.4 container:
docker run -d \
    --name ghost-mysql \
    --network ghost-network \
    -e MYSQL_ROOT_PASSWORD=ghost-root-password \
    -e MYSQL_DATABASE=ghost \
    -e MYSQL_USER=ghost \
    -e MYSQL_PASSWORD=ghost-password \
    mysql:8.4
  1. Start a webhook receiver on the same network:
docker run -d \
    --name webhook-receiver \
    --network ghost-network \
    example/webhook-receiver:latest

The receiver listens on port 3000 and exposes: POST /api/webhooks/posts

  1. Start Ghost in production mode on the same network:
docker run -d \
    --name ghost \
    --network ghost-network \
    -p 2368:2368 \
    -e NODE_ENV=production \
    -e url=http://localhost:2368 \
    -e database__client=mysql \
    -e database__connection__host=ghost-mysql \
    -e database__connection__port=3306 \
    -e database__connection__user=ghost \
    -e database__connection__password=ghost-password \
    -e database__connection__database=ghost \
    ghost:6.63.0
  1. Verify that Docker DNS resolves the receiver from the Ghost container:
docker exec ghost node -e \
    'require("dns").lookup("webhook-receiver", console.log)'
The hostname resolves to a private Docker network address such as:

null 172.18.0.3 4
  1. Verify that the receiver is reachable from the Ghost container:
docker exec ghost node -e '
const http = require("http");

const request = http.request(
    "http://webhook-receiver:3000/api/webhooks/posts",
    {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        }
    },
    response => {
        console.log("Status:", response.statusCode);
        response.resume();
    }
);

request.on("error", console.error);
request.end("{}");
'
  1. In Ghost Admin, create a custom integration and configure a webhook with this target URL:
    http://webhook-receiver:3000/api/webhooks/posts

  2. Trigger the webhook by performing the configured event, such as editing or publishing a post.

  3. Observe that Ghost rejects the destination because it resolves to a private Docker network address.

The validation occurs in errorIfHostnameResolvesToPrivateIp():
https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/server/lib/request-external.js

Ghost Version

6.63.0

Node.js Version

22.23.2

How did you install Ghost?

Official ghost:6.63.0 Docker image running on a self-hosted Docker host.

Database type

MySQL 8

Browser & OS version

Not applicable.

Relevant log / error output
Triggering webhook for "post.edited" with url "http://webhook-receiver:3000/api/webhooks/posts"

ERROR [WEBHOOK_DELIVERY_FAILURE]
url=http://webhook-receiver:3000/api/webhooks/posts
status=none
error_code=URL_PRIVATE_INVALID
message=URL resolves to a non-permitted private IP block

RequestError: URL resolves to a non-permitted private IP block
    at Request._beforeError
    at Request.flush
    at errorIfHostnameResolvesToPrivateIp
    at async Request._makeRequest
Code of Conduct
  • I agree to be friendly and polite to people in this repository

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with errorIfHostnameResolvesToPrivateIp() in ghost/core/core/server/lib/request-external.js and trace how webhook requests reach that validation. Define how an exact hostname-and-port allowlist should be configured and applied without permitting other private destinations. Done means the documented Docker webhook target is accepted while non-allowlisted private targets remain rejected.

Written by the indexing model from the issue text.

Assessment

Tech stack
docker, nodejs
Domain
backend, security
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.