Webhooks cannot target explicitly trusted private services in Production
Nobody has claimed this yet.
- 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
- Create a private Docker network:
docker network create ghost-network
- 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
- 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
- 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
- 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
- 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("{}");
'
-
In Ghost Admin, create a custom integration and configure a webhook with this target URL:
http://webhook-receiver:3000/api/webhooks/posts -
Trigger the webhook by performing the configured event, such as editing or publishing a post.
-
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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