This document describes the security model for df.http() and
df.http_multipart(), which make outbound HTTP(S) requests from within the
PostgreSQL background worker. Both use the same destination policy.
The same HTTP policy applies to df.http_multipart, which has its own function
privilege check. For endpoint credentials, see
the credential security contract.
- Startup Policy
- 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
Since v0.2.9, pg_durable.http_security selects the outbound HTTP policy at
server startup. It is a superuser-only, Postmaster-context enum. Configure
it in postgresql.conf or through an authorized ALTER SYSTEM SET, then
restart PostgreSQL. Reloads, SET, SET LOCAL, role/database defaults, and
workflow inputs cannot change the running policy.
An invalid startup value prevents PostgreSQL from starting rather than silently falling back to the default policy.
| Mode | What is allowed | Use case |
|---|---|---|
disabled |
Nothing; both HTTP constructors and execution paths reject requests | Deployments that don't need HTTP |
restricted (default) |
HTTPS to configured domains, defaulting to Azure subdomains plus api.github.com; bare IPs and private DNS results blocked; no proxies |
Production HTTP access |
unrestricted |
HTTP and HTTPS to any destination, including private networks; domain and IP restrictions bypassed | Local development only |
# postgresql.conf
pg_durable.http_security = 'restricted'The domain allow-list can be replaced with pg_durable.http_allowed_domains.
It cannot enable HTTP in disabled mode or relax restricted mode's other
protections. Both enabled modes retain function privilege checks, TLS
certificate verification, and redirect blocking. Unrestricted mode permits
system/environment proxies and must not be used on untrusted deployments.
Local development and E2E launchers use restricted mode and add
httpbingo.org to their test allow-list. The source-built Dockerfile does the
same. Released packages default to restricted HTTP; the published demo image
(Dockerfile.release) explicitly selects restricted mode with the default
Azure/GitHub allow-list.
When pg_durable.http_security = 'disabled', df.http() fails at the point
df.http() is called in SQL with:
df.http() is disabled. Configure pg_durable.http_security = 'restricted' and restart the server to enable outbound HTTP requests.
Crafted workflow JSON can bypass the constructors, so both HTTP activities enforce the same block again at execution time.
The HTTP Cargo features have been removed. Restricted mode is the default and
matches the former Azure/GitHub policy. For installations previously built
without HTTP support, explicitly select disabled before restarting with
the new binary to keep HTTP blocked. HTTP function privileges are unchanged.
| Previous build | Replacement configuration |
|---|---|
| No HTTP feature | Set pg_durable.http_security = 'disabled' |
http-allow-azure-domains |
Use the default restricted mode |
http-allow-test-domains |
Select restricted and explicitly include httpbingo.org in the complete domain allow-list |
http-allow-all |
Select unrestricted, on development servers only |
No extension SQL migration is required for these settings. The new binary uses them with older extension schemas as well. After a restart, pending requests and retries use the new policy; recorded activity results still replay normally. HTTP privileges are unchanged.
┌──────────────────────────────────────────────────────────┐
│ 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 │
│ • Bypassed only in unrestricted mode │
│ │
├──────────────────────────────────────────────────────────┤
│ Layer 2: Endpoint Allow-List │
│ │
│ • Bare IPv4/IPv6 addresses always rejected │
│ • Hostname must match an approved suffix or exact name │
│ • Bypassed only in unrestricted mode │
│ • All requests blocked in disabled mode │
│ │
└──────────────────────────────────────────────────────────┘
Both HTTP activities enforce these layers before sending a request. The domain allow-list is configurable at server startup; the IP blocklist is not. Neither has a per-session or per-role override, and superuser HTTP requests are subject to the same destination policy.
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,
and no other effective grant remains, the next execution attempt fails before
sending a request. Revocation does not cancel a request already in progress.
The check is repeated immediately before sending, after credential-catalog waits
and preparation. Source OID/installation UUID is validated with that final
privilege read. The check-to-send interval is not atomic with source DROP or
privilege revocation.
The activity selects the required HTTP function signature from the node's actual destination and body mode, then checks the submitting role before network activity:
SELECT COALESCE(pg_catalog.has_function_privilege(
role.oid, pg_catalog.to_regprocedure($http_signature)::pg_catalog.oid,
'EXECUTE'), false)
FROM pg_catalog.pg_roles AS role
WHERE role.rolname OPERATOR(pg_catalog.=) $submitted_by;The signature is selected internally based on whether or not the node would
have been created using a df.http_endpoint (the type that df.endpoint
returns), not supplied as a permission override in node JSON. Role lookup
uses the exact catalog name. Missing functions or roles fail closed;
hand-crafted nodes cannot bypass the check.
has_function_privilege honours PostgreSQL's standard privilege model:
superusers always return true; regular roles return true only when an
effective grant exists, whether direct, inherited from another role or granted
to PUBLIC.
Multipart activities use the same check for df.http_multipart. Manage the full
HTTP permission set through df.grant_usage and df.revoke_usage.
df.with_http_options(text,jsonb) is a node modifier, not a network operation.
Like other combinators, it uses ordinary df schema access and default PUBLIC
EXECUTE. Wrapping a hand-crafted HTTP node does not bypass the activity's
privilege check. It supports explicit secret bindings
and body limits and response retention.
SQL NULL and {} preserve the original node text.
HTTP access is opt-in and separate from general df access.
Use df.grant_usage() with include_http => true to enable normal and multipart
HTTP with either URLs or endpoints:
SELECT df.grant_usage('my_role', include_http => true);df.grant_usage('my_role') (without include_http) grants all standard df
privileges but does not grant either HTTP function. The helper is additive:
include_http => false does not revoke previously granted or inherited HTTP
access. Ordinary helpers retain PostgreSQL's default PUBLIC EXECUTE; schema
USAGE is their access gate. Sensitive functions are granted explicitly.
To remove HTTP access while retaining standard df access, revoke the current
helper-managed grants and regrant without HTTP:
SELECT df.revoke_usage('my_role');
SELECT df.grant_usage('my_role');Once no effective HTTP grant remains, later execution attempts fail with a
privilege error. Other df functions remain accessible. Check for grants through
PUBLIC or inherited roles: revoking a direct grant does not remove those paths.
df.revoke_usage('my_role') also revokes standard df access and sensitive
function grants within the caller's grant authority. It does not erase
independent grants through PUBLIC or other roles.
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;Calling df.grant_usage(role, include_http => false) does not revoke the legacy
grant or warn about residual access. Use has_function_privilege to check the
role's effective permissions after changing grants.
Endpoint support adds new functions without copying existing grants onto them.
After upgrading, run df.grant_usage(role, include_http => true) for roles that
need endpoint requests. Existing TEXT function OIDs and ACLs remain unchanged.
df.grant_usage() and df.revoke_usage() are admin-only functions.
EXECUTE is revoked from PUBLIC at CREATE EXTENSION time, but administration
can be delegated. A role must have permission to call a helper, and its operations
are additionally constrained by PostgreSQL's native grant authority because the
helpers run as SECURITY INVOKER.
df.grant_usage(..., with_grant => true) grants privileges with WITH GRANT OPTION, including execution of the grant/revoke helpers. Such a delegated admin
can grant only privileges it has authority to grant; execution permission alone
does not confer the extension owner's privileges.
df.grant_usage issues explicit schema, table and sensitive-function grants.
It does not use a blanket function grant followed by revocations. When granting
HTTP access, the caller must be able to grant the complete HTTP function set; otherwise the
call fails rather than silently skipping the HTTP grant.
The HTTP activities enforce the privilege check in every
pg_durable.http_security mode, including unrestricted. In disabled mode,
DSL constructors reject requests immediately. Crafted nodes that bypass the
constructors still undergo the activity's privilege check before the destination
policy rejects them. HTTP function privileges never override the startup
destination policy.
df.endpoint returns the native df.http_endpoint type. TEXT arguments to the
HTTP constructors are not decoded as endpoint references. An endpoint value does
not grant authority. Normal and multipart activities
first check their existing HTTP function grant, then resolve the foreign server
and the submitting role's user mapping on a connection authenticated as that role.
Server USAGE is mandatory. Catalogs are in the trusted origin installation
(control for legacy control-origin work), regardless of the SQL target. Caller-supplied
database/identity fields in node JSON cannot select another credential catalog or
override the trusted submitting identity.
One request uses one read-only REPEATABLE READ snapshot for endpoint configuration
and all referenced mappings, including bindings from other servers. Catalog rows
are reused within the attempt, not cached across attempts. This prevents atomic
catalog updates from producing mixed destination/credential generations. The
caller connection acquires the same admission slot as SQL execution and is closed,
releasing the slot, before network I/O. Requests without catalog references open
no caller connection.
Satellite catalog identity is validated on that same connection after admission
and installation-lock acquisition. The snapshot remains consistent for the
attempt; the final source/HTTP privilege check does not reread a subset of its
secrets or change their snapshot semantics.
The catalog-only ownership/type/membership precheck runs before the repeatable-read
transaction. A post-lock catalog recheck establishes its first snapshot, and only
then is the installation UUID read. This rejects replacement views without
planning a query against them, including replacement during a lock wait.
Server owners must be trusted with credentials sent through their endpoints: changing a destination can redirect subsequent authenticated requests, even when the owner's catalog view cannot reveal the caller's mapping values.
Path composition preserves the base URL's authority and path prefix. Traversal,
protocol-relative paths and encoded path separators are rejected after variable
substitution as well as at construction. The final URL, including any credential
query parameters, passes the same scheme, allow-list and DNS protections as a raw
URL. Endpoint requests cannot supply Host, duplicate a configured credential
header, or override credential query parameter names. Headers carrying resolved
credentials are added only after destination validation.
Only the server name and path template enter node configuration and recorded request inputs. Resolved credentials stay within the activity. Request diagnostics redact the composed URL; response echoes and response secrets remain outside that guarantee. Request bodies are not scanned for secret markers. See Calling an Endpoint for the API and catalog requirements.
df.secret(server, key) returns a JSONB reference, not a value or an embeddable
marker. Only named header/query/form slots in secret_bindings interpret these
references. Ordinary request fields, literal form_fields, multipart bytes and
resolved strings are never searched for secret markers. Binding maps are trusted
workflow configuration, not untrusted payload data. Credential-bearing destinations
must also be trusted; a reference's server name selects the credential namespace,
not a restriction on which destination can receive it.
Activities validate field shapes, reject conflicts with ordinary fields and
endpoint authentication, and resolve each referenced server under submitted_by
in the request's origin-database snapshot after destination policy checks.
Server USAGE and a caller-owned mapping are
required even for auth_scheme 'none'. With that scheme, a named-secret-only
server may omit base_url; endpoint requests fail without it. A supplied URL
still undergoes the standard validation. Named values come only from individual
"secret.<key>" user-mapping options, not ambient identity, endpoint-authentication
options or server options. The prefix is a credential namespace, not an instruction
to interpret the value. Native ADD, SET and DROP update one credential
without rewriting unrelated options.
Header values are validated and marked sensitive. Query/form names and values are form-urlencoded; query insertion cannot change the destination authority. Form mode owns body framing/content type, rejects raw body/multipart combinations, and leaves ordinary field values literal. Missing secrets fail without fallback or values in error messages. Request URL diagnostics are redacted after secret query insertion; response credentials remain outside this guarantee.
See Explicit Secret Bindings for API examples and deferred general-composition cases.
| 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.
Restricted mode disables reqwest's system and environment proxy discovery.
An HTTP proxy resolves the destination itself, outside SsrfSafeResolver, so
inheriting HTTP_PROXY, HTTPS_PROXY, or platform proxy settings would bypass
the destination-IP check.
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_allowlist blocks all bare IPs in restricted mode, so these never
reach the resolver. Only development-only unrestricted mode bypasses
this rule.
Since v0.2.9, pg_durable.http_allowed_domains is the complete allow-list for
both HTTP activities in restricted mode. For example, in postgresql.conf:
pg_durable.http_security = 'restricted'
pg_durable.http_allowed_domains = 'api.github.com, *.blob.core.windows.net'An exact hostname allows only that host. *.example.com allows subdomains at
any depth, such as a.example.com and a.b.example.com, but not example.com
itself. Add a separate exact entry to allow the apex. Matching is
case-insensitive and uses the same IDNA/Punycode representation as request URL
parsing. Use UTF-8 internationalized names or their ASCII/Punycode spelling.
Separate entries with commas; whitespace around entries is ignored. Entries
must be DNS hostnames, optionally prefixed with *.. URLs, ports, IP addresses,
CIDRs, percent escapes, trailing dots, other wildcard forms, and empty entries
inside a nonempty list are rejected. A malformed value rejects the whole
setting, not just the offending entry. A malformed startup value prevents
PostgreSQL from starting rather than falling back to a potentially broader
default.
An explicit value replaces all defaults. It does not implicitly retain Azure, GitHub, or test domains. An empty or whitespace-only value denies all domains in restricted mode:
pg_durable.http_allowed_domains = ''This is a Postmaster-context setting. Set it in postgresql.conf or through
an authorized ALTER SYSTEM SET, then restart PostgreSQL; a reload alone is
not enough. SET, SET LOCAL, and role/database settings cannot override it.
All users can inspect the active value with
SHOW pg_durable.http_allowed_domains.
The restarted worker applies the new policy to requests it executes, including pending activities and retries. Previously recorded activity results replay normally. There is no per-workflow snapshot of the old policy.
The setting does not relax HTTPS, IP blocking, proxy restrictions, redirects, or function privileges. A hostname that resolves to a blocked IP is still blocked. Disabled mode remains disabled regardless of the list; unrestricted mode bypasses it, even when it is empty.
With no override, the following subdomain patterns are allowed. Apex domains
(e.g. blob.core.windows.net) require a separate exact entry.
| Pattern | 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 |
Test domains are not production defaults. The development and E2E launchers explicitly include this host in their configured allow-list:
| Domain | Purpose |
|---|---|
httpbingo.org |
HTTP echo service (used in HTTP E2E tests) |
All bare IPv4 and IPv6 addresses are rejected by validate_allowlist in
restricted mode, regardless of the configured domain list.
Because the allowlist blocks all bare IPs, there is no separate IP-literal
check; the allowlist is the definitive gate for IP-literal URLs.
The host judged by the allow-list is read from the url::Url that is then
handed to reqwest, never from the caller's string. Comparing a separately
parsed host against the allow-list would let the two parsers disagree: WHATWG
ends the authority of an http(s) URL at \, so in
https://evil.example\@acct.blob.core.windows.net/ the host is
evil.example and the allow-listed name is merely path. A scan that read the
name after the last @ would approve a request aimed elsewhere — and with an
IP literal in that position it would also clear the bare-IP rule, the only
gate for targets that skip DNS.
Judging the parsed host means the allow-list follows canonicalisation as
reqwest performs it: percent-encoded host characters are decoded,
non-dotted IPv4 notation (http://2130706433/) becomes an IP literal, and
internationalised names are compared in their Punycode form.
Restricted mode accepts only https://. This prevents credentials and request
bodies from being transmitted over plaintext connections. Plaintext http://
is available only in development-only unrestricted mode.
All other schemes (file://, ftp://, gopher://, etc.) are rejected before
any DNS resolution or connection attempt. Scheme validation runs both when the
DSL node is created and when the canonical parsed URL is executed.
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.
The background worker reuses a reqwest::Client for its startup security mode, so
its connection pool is reused across every HTTP node — and therefore across
database users. This is deliberate: a pooled connection carries no caller
identity. Credentials travel as per-request headers (Authorization, SAS
tokens in the URL), never as connection state, so one role's request cannot
inherit another's authentication by landing on a warm connection. The
per-node timeout is applied to each request rather than to the client for the
same reason — it is caller configuration, not connection state.
Client construction is attempted on first use. If it fails with Failed to create HTTP client, that error is cached for the lifetime of the background
worker process: subsequent HTTP and multipart activities return the same
error without retrying construction. After addressing the cause, restart the
background worker process (or PostgreSQL) to allow another construction attempt;
retrying an activity alone does not clear the error. Ordinary DNS, connection,
and request-timeout failures happen after construction and are not cached this
way, so they do not require a worker restart.
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, redacted (see below)- 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.
A URL is a credential carrier: an Azure SAS token lives entirely in the query
string, and ?api-key= / ?code= are common elsewhere. Worker request diagnostics
redact these values before logging URLs or including them in errors. The server
log has no RLS or pg_durable.retention_days, and frequently has a separate
shipping and backup path.
| Component | Treatment |
|---|---|
| Scheme, host, port, path | Preserved. The URL is reparsed, so a logged line may be normalized (host lowercased, default port dropped) relative to what the workflow supplied. |
| Query parameter names | Preserved except for ambiguous pairs below |
| Query parameter values | Replaced with <redacted> |
| Bare query tokens and pairs with empty or padding-only values | Replaced whole: token, token=, and token== can all be opaque credentials |
userinfo@ |
Replaced with <redacted>@, keeping the host |
| Fragment | Replaced whole |
| Unparseable input | Replaced whole — redaction fails closed and never echoes back a string it could not parse |
Parsing uses the url crate, so IPv6 authorities, percent-encoding, default
ports and userinfo follow the spec rather than ad-hoc string splitting.
The HTTP client's attached request URL is removed before formatting its errors,
including response-body read failures. Explicitly reported request URLs are
redacted as above. This matters because failed nodes store their error in
df.nodes.result and durable execution history, not just in the log.
Do not put credentials in paths or parameter names: those can remain visible. Request headers and bodies are not directly included in request traces, but an endpoint can echo them in its response.
Not covered by URL redaction: stored request inputs, response headers and inline response bodies. Successful completion logs only the result's byte count, but inline response-body previews appear in 5xx errors. A response containing a credential, including an echoed request URL or a token returned by an endpoint, can still expose it in logs and stored results. URL redaction does not make
df.varssecret storage; see Variables and secrets.
Use response: "metadata" or response: "discard" through df.with_http_options
when the workflow does not need the response body. These modes exclude body
bytes from durable results and 5xx error previews, without saving another copy.
metadata records a SHA-256 digest; use discard when a digest is unnecessary
or would reveal information about a predictable response.
Response headers are controlled separately. response_headers: [] retains none;
"safe" selects common metadata headers, and an explicit array selects named
headers. The default remains "all". Allow-listed values are not redacted and
can still contain sensitive data supplied by the server.
max_request_bytes and max_response_bytes are optional, caller-controlled
limits, not administrator-enforced resource quotas. The response cap is checked
while reading, after automatic decompression. These options do not remove
request templates, captured variables, or earlier results from history, and
do not automatically redact secrets from inline responses. See
Body Limits and Response Retention.
response: "sink" with into stores raw response bytes in a caller-provided
table, keeping them out of durable activity results and 5xx previews. Writes
authenticate as the submitting role and obey table privileges and RLS. The
target database comes from the workflow's captured execution context; a forged
database or submitting-role field in an HTTP node cannot redirect sink writes.
When no explicit execution database is selected, a satellite stores in its origin
database. Source identity is checked again after sink-slot/connection waits and
before remote storage. Own-origin writes validate the exact database/installation
identity within the sink transaction; credential lookup still uses the origin,
not an explicit remote sink target. Remote storage admission is not atomic with
source removal and cannot undo an already-committed row.
The sink preserves the submitting connection's role/database search_path
defaults for triggers and other table-side code. Its internal queries qualify
catalog functions, types, and operators explicitly; the sink does not depend
on a restricted search path for those lookups.
Only permanent, logged storage is accepted. The row's key and body digest are verified before commit, so an insert suppressed or altered by a trigger fails instead of producing an invalid reference. Database failures expose the operation and SQLSTATE, not messages or details that might include response bytes.
This is a data-placement control, not encryption or automatic response-secret handling. Table access, backups, replication, database logging, and sink triggers remain subject to the deployment's data policy. PostgreSQL logging or trigger messages can expose values just as with ordinary table writes. Response headers are controlled separately. The caller owns sink retention, including rows left by attempts whose completion was never recorded. See Storing Responses in a Table.
| Scenario | Message |
|---|---|
| No HTTP EXECUTE privilege | Blocked: role '{role}' does not have EXECUTE privilege on {function}() for this request. The error identifies the required signature and recommends df.grant_usage with include_http => true. |
| HTTP disabled | Blocked: outbound HTTP requests are disabled. Configure pg_durable.http_security = 'restricted' and restart the server to enable them. |
| Plaintext HTTP in restricted mode | Blocked: plaintext HTTP is not permitted in restricted mode. HTTPS is required. |
| Unsupported scheme | Blocked: unsupported URL scheme. Only {allowed} is allowed. where {allowed} is https in restricted mode or http and https in unrestricted mode |
| Bare IP address | Blocked: requests to bare IP addresses are not permitted. Use an approved service hostname instead. |
| Non-allowed domain | Blocked: '{host}' is not in the allowed endpoint list. Configure pg_durable.http_allowed_domains to allow this hostname. |
| Invalid domain-list configuration | invalid value for parameter "pg_durable.http_allowed_domains" with the offending entry and reason |
| 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 (disabled mode) | df.http() is disabled. Configure pg_durable.http_security = 'restricted' and restart the server to enable outbound HTTP requests. |
| Request body too large | HTTP request body exceeds max_request_bytes ({limit} bytes) |
| Response body too large | HTTP response body exceeds max_response_bytes ({limit} bytes) |
| Sink database failure | HTTP response sink {operation} failed (SQLSTATE {code}) |
| Sink storage timeout | HTTP response sink timed out before completion |
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 |
| Administrator-enforced body limits | Caller-controlled HTTP options are not resource quotas |
| 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. |