This document describes the security model for df.http() — the durable HTTP
activity that lets workflows make outbound HTTP(S) requests from within the
PostgreSQL background worker.
- Feature Flags
- Three-Layer Security Model
- Layer 0: PostgreSQL Privilege Check
- Layer 1: IP Blocklist (SSRF protection)
- Layer 2: Endpoint Allow-List
- Additional Hardening
- Audit Logging
- Error Messages
- Out of Scope
Outbound HTTP access is controlled entirely by Cargo features at build time. The database cannot override these choices — they cannot be changed with GUCs or SQL.
| Feature | What is allowed | Use case |
|---|---|---|
| (none) | Nothing — df.http() errors immediately at DSL time and at execution time |
Deployments that don't need HTTP |
http-allow-azure-domains |
Subdomains of the Azure allow-list plus api.github.com; bare IPs blocked; redirects blocked |
Production |
http-allow-test-domains |
Everything in http-allow-azure-domains plus httpbingo.org |
E2E testing; implies http-allow-azure-domains |
http-allow-all |
All URLs; SSRF IP blocklist and allow-list are both disabled | Local development only |
The scripts and CI use http-allow-test-domains so that the HTTP E2E tests
pass — this includes the source-built Dockerfile used for local dev and CI.
The released Debian packages are built with http-allow-azure-domains, so the
published Docker image (Dockerfile.release, which installs that package)
inherits the http-allow-azure-domains policy.
df.http() fails at the point df.http() is called in SQL with:
df.http() is disabled. Rebuild with the 'http-allow-azure-domains' Cargo feature to enable outbound HTTP requests.
Because df.nodes rows can be inserted by hand (bypassing the DSL), the same
block is enforced again at execution time inside execute_http.rs via
validate_url_allowlist.
┌──────────────────────────────────────────────────────────┐
│ Layer 0: PostgreSQL Privilege Check │
│ │
│ • submitted_by role must have EXECUTE on df.http() │
│ • Checked at execution time against the live catalog │
│ • Blocks bypass via raw df.start() JSON injection │
│ • Runs before any network activity │
│ │
├──────────────────────────────────────────────────────────┤
│ Layer 1: IP Blocklist (SSRF protection) │
│ │
│ • Private/reserved IP ranges blocked after DNS │
│ • IPv4-mapped IPv6 (::ffff:A.B.C.D) unwrapped + checked │
│ • IP literals in URLs blocked before DNS │
│ • DNS rebinding prevented via inline resolver check │
│ • Disabled only under http-allow-all │
│ │
├──────────────────────────────────────────────────────────┤
│ Layer 2: Endpoint Allow-List │
│ │
│ • Bare IPv4/IPv6 addresses always rejected │
│ • Hostname must match an approved suffix or exact name │
│ • Disabled (allow everything) under http-allow-all │
│ • Empty (block everything) when no http feature set │
│ │
└──────────────────────────────────────────────────────────┘
All three layers run inside execute_http.rs before the request is sent.
There is no GUC, no table override, no superuser bypass for Layers 1 and 2.
A user granted df.grant_usage() can call df.start() directly with a
hand-crafted Durofut JSON string, inserting an HTTP node without ever calling
df.http(). The DSL-time guard inside df.http() does not run in that path.
To close this gap, execute_http checks at execution time whether the
submitted_by role recorded in the node still holds EXECUTE privilege on
df.http(). If the role's grant has been revoked since the node was created,
the node fails immediately.
execute_http runs the following check before any network activity:
SELECT has_function_privilege($submitted_by::regrole,
'df.http(text,text,text,jsonb,integer)'::regprocedure,
'EXECUTE')has_function_privilege honours PostgreSQL's standard privilege model:
superusers always return true; regular roles return true only when an
explicit GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO <role> (or a role that
inherits one) is in effect.
HTTP access is opt-in and separate from general df access.
Use df.grant_usage() with include_http => true:
SELECT df.grant_usage('my_role', include_http => true);Or grant directly:
GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO my_role;df.grant_usage('my_role') (without include_http) grants all standard df
privileges but not df.http(). HTTP access must be explicitly opted in to.
To remove HTTP access without removing all df access:
REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM my_role;After this, any existing or future HTTP nodes submitted by my_role will fail
at execution time with a "permission denied" error. All other df functions
remain accessible.
df.revoke_usage('my_role') removes all df access, including df.http().
Fresh installs (v0.2.0+) have EXECUTE on df.http() revoked from PUBLIC
at CREATE EXTENSION time. Installs that upgraded from v0.1.1 retain the
PUBLIC grant that v0.1.1 issued — the upgrade script does not revoke it.
If an upgraded install should enforce opt-in HTTP permissions, the admin must run manually:
REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC;When df.grant_usage(role, include_http => false) is called and the role still
has effective HTTP access via the PUBLIC grant (or another inherited grant), a
WARNING is emitted to signal that the revocation had no net effect.
df.grant_usage() and df.revoke_usage() are admin-only functions.
EXECUTE is revoked from PUBLIC at CREATE EXTENSION time, so only
superusers can call them.
Caution:
df.grant_usage()internally runsGRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA df, which temporarily includesdf.grant_usage()anddf.revoke_usage()themselves before the function immediately revokes them from the target role. If an admin replicates the blanketGRANTmanually without the matchingREVOKEs, the target role will gain access to these admin helpers. Always usedf.grant_usage()rather than hand-crafting the equivalentGRANTstatements.
The privilege check runs regardless of which HTTP Cargo feature is enabled. When no HTTP feature is compiled in, the request is still blocked later by the DSL-time guard and by execution-time URL validation, but the privilege check remains compiled in and still runs before any network activity.
| CIDR | Description |
|---|---|
0.0.0.0/8 |
"This" network |
10.0.0.0/8 |
RFC 1918 private |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local — includes cloud metadata at 169.254.169.254 |
172.16.0.0/12 |
RFC 1918 private |
192.168.0.0/16 |
RFC 1918 private |
| Range | Description |
|---|---|
::/128 |
Unspecified |
::1/128 |
Loopback |
fe80::/10 |
Link-local |
fc00::/7 |
Unique local (ULA) |
Addresses of the form ::ffff:A.B.C.D are unwrapped to their embedded IPv4
before the blocklist check, preventing bypasses like ::ffff:169.254.169.254.
The SSRF-safe DNS resolver (SsrfSafeResolver) wraps the system resolver and
filters blocked IPs inline — the same IP that passes the check is the one
used for the TCP connection. There is no window for a rebinding attack.
Only the single IP address that reqwest actually connects to is checked. If
DNS returns multiple A/AAAA records, the others are not checked because they
are never used. This is intentional, not a gap — checking unused addresses
would create false positives without any security benefit.
Bare IP literals in URLs (e.g. http://169.254.169.254/...) bypass DNS
entirely — reqwest connects directly without calling the resolver.
validate_url_allowlist blocks all bare IPs unconditionally, so these never
reach the resolver.
Only subdomains of the following suffixes are permitted. Apex domains (e.g.
blob.core.windows.net without a subdomain label) are rejected.
| Suffix | Service |
|---|---|
.blob.core.windows.net |
Azure Blob Storage |
.blob.storage.azure.net |
Azure Blob Storage (secondary) |
.queue.core.windows.net |
Azure Queue Storage |
.table.core.windows.net |
Azure Table Storage |
.file.core.windows.net |
Azure Files |
.azurewebsites.net |
Azure App Service |
.azure-api.net |
Azure API Management |
.documents.azure.com |
Azure Cosmos DB |
.servicebus.windows.net |
Azure Service Bus |
.openai.azure.com |
Azure OpenAI |
.cognitiveservices.azure.com |
Azure Cognitive Services |
.vault.azure.net |
Azure Key Vault |
.redis.cache.windows.net |
Azure Cache for Redis |
.database.windows.net |
Azure SQL Database |
.kusto.windows.net |
Azure Data Explorer |
.azurefd.net |
Azure Front Door |
.azureedge.net |
Azure CDN |
.azure-devices.net |
Azure IoT Hub |
.trafficmanager.net |
Azure Traffic Manager |
.cloudapp.azure.com |
Azure Cloud App |
Matched exactly — subdomains and lookalikes are rejected.
| Domain | Purpose |
|---|---|
api.github.com |
GitHub API |
| Domain | Purpose |
|---|---|
httpbingo.org |
HTTP echo service (used in HTTP E2E tests) |
All bare IPv4 and IPv6 addresses are rejected by validate_url_allowlist
regardless of feature flag — even under http-allow-azure-domains.
Because the allowlist blocks all bare IPs, there is no separate IP-literal
check; the allowlist is the definitive gate for IP-literal URLs.
Only http:// and https:// are accepted. All other schemes (file://,
ftp://, gopher://, etc.) are rejected before any DNS resolution or
connection attempt.
reqwest is built with Policy::none() (no redirect following). This
prevents redirect-based bypasses where an attacker hosts a public server that
returns a 302 Location: http://169.254.169.254/... — since the redirect
target is an IP literal, the DNS resolver would never be called.
Every HTTP attempt (allowed or blocked) is logged via ctx.trace_info with:
submitted_by— the role that calleddf.start()at the time the node was created (captured ascurrent_userin the DSL and stored inFunctionNode)url— the requested URL- Block reason tag —
(scheme),(allowlist), or(ip)in the log prefix
Resolved IP addresses are not included in error messages or logs to avoid leaking internal network topology to potentially malicious users.
| Scenario | Message |
|---|---|
| No EXECUTE privilege on df.http() | Blocked: role '{role}' does not have EXECUTE privilege on df.http(). Grant EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer) TO {role} to allow HTTP requests. |
| HTTP disabled (no feature) | Blocked: outbound HTTP requests are disabled. Rebuild with the 'http-allow-azure-domains' Cargo feature to enable them. |
| Unsupported scheme | Blocked: unsupported URL scheme '{scheme}'. Only http and https are allowed. |
| Bare IP address | Blocked: requests to bare IP addresses are not permitted. Use an approved Azure service hostname instead. |
| Non-allowed domain | Blocked: '{host}' is not in the allowed endpoint list. Only requests to approved Azure service domains are permitted. |
| Blocked IP (literal or DNS) | Blocked: the resolved IP address for '{host}' is in a restricted range. df.http() cannot access private or internal network addresses. |
| DSL-time (no feature) | df.http() is disabled. Rebuild with the 'http-allow-azure-domains' Cargo feature to enable outbound HTTP requests. |
These items are deferred to a future customer-level access control spec:
| Item | Notes |
|---|---|
| Per-role URL/domain allowlists configurable by admins | GUC or table-driven |
| Rate limiting | DoS mitigation, not SSRF |
| Response size limits | Resource management |
| Port restrictions | Low value at this layer |
| Egress filtering to attacker-controlled domains | Separate threat (T9) |
| Azure Private Endpoint | Private Endpoints assign private RFC 1918 addresses to Azure services, which the IP blocklist currently blocks. Supporting Private Endpoints requires a targeted exemption mechanism that does not open all private ranges. Design is deferred to a future spec. |