SSRF protection
A service that fetches any URL you give it could be pointed at your own network: an admin panel on 192.168.1.1, a database on 10.0.0.5, or the cloud metadata service that hands out credentials. PullMD blocks these requests by default.
Before every fetch, PullMD resolves the host name and checks each address. It refuses addresses in these ranges:
- Private networks (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - Loopback (
127.0.0.0/8,::1) - Link-local, including the cloud metadata address
169.254.169.254 - Carrier-grade NAT (
100.64.0.0/10), including Alibaba Cloud's metadata address100.100.100.200 0.0.0.0/8, multicast, reserved and documentation ranges- IPv6 loopback, link-local, multicast and unique-local (
fc00::/7, which covers AWS's IPv6 metadata addressfd00:ec2::254) - Any of the blocked IPv4 ranges written in IPv6 form (IPv4-mapped, NAT64, 6to4, IPv4-compatible)
This request targets the cloud metadata address:
curl "http://localhost:3000/api?url=http://169.254.169.254/latest/meta-data/"
{ "error": "URL not allowed: blocked address 169.254.169.254 for host 169.254.169.254" }
PullMD answers 403. It refuses a host name that doesn't resolve at all the same way, with could not resolve host in the message. Only http and https URLs are fetched; other schemes such as ftp: are refused with scheme not allowed.
Where it applies
GET /apiand the MCPread_urltool check the URL before doing anything else.GET /api/streamchecks it too and reports a refusal as anerrorevent, because the stream has already started.- Every redirect is checked before it's followed, so a public URL that redirects to an internal address is stopped at the redirect.
- The browser render (Playwright sidecar) applies the same rules to everything Chromium loads on its own: redirects, meta refresh, script navigation, iframes, subresources and WebSockets. A blocked subresource is dropped and the render continues. With
render=forceorextractor=playwright, a page that navigates to a blocked address returns403. On the automatic render, PullMD keeps the static result and notes the refusal inextractorReason(extractor_reasonin Markdown frontmatter). This needs v3.15.2 or later on both thepullmdandpullmd-playwrightimages. - A refused URL is never cached and never gets a share link.
Allow an internal host
To convert pages from an intranet wiki or a document server on your network, list them in PULLMD_ALLOWED_HOSTS:
PULLMD_ALLOWED_HOSTS=10.0.5.0/24,wiki.internal
Entries are CIDR ranges, single IP addresses, or exact host names (matched case-insensitively, on any port, with no subdomain matching), separated by commas. A host-name entry skips the address checks, so list only names you control. Malformed CIDR entries are dropped without a warning. Empty (the default) blocks every internal address. PullMD passes the list to the Playwright sidecar with each render, so you set it in one place.
PULLMD_ALLOWED_HOSTS to the container. Add - PULLMD_ALLOWED_HOSTS=${PULLMD_ALLOWED_HOSTS:-} to the pullmd service's environment: list. See Installation.Known limit
Static fetches connect to the addresses the check validated, one DNS query per request, and every redirect hop is pinned the same way. The Playwright sidecar can't pin its connections, because Chromium resolves names itself. A DNS-rebinding attack on a browser render, where a name resolves to a public address for the check and an internal one for the connection, isn't fully prevented.
For a boundary that doesn't depend on PullMD's own checks, put the Playwright sidecar on a Docker network shared only with pullmd. The sidecar doesn't need to reach your other containers, unless an internal host you list in PULLMD_ALLOWED_HOSTS is reachable only on a Docker network, in which case the sidecar needs access to it.