Skip to content

Latest commit

 

History

History
703 lines (552 loc) · 34.7 KB

http-security.md

File metadata and controls

703 lines (552 loc) · 34.7 KB

HTTP Security in pg_durable

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.


Table of Contents

  1. Startup Policy
  2. Three-Layer Security Model
  3. Layer 0: PostgreSQL Privilege Check
  4. Layer 1: IP Blocklist (SSRF protection)
  5. Layer 2: Endpoint Allow-List
  6. Additional Hardening
  7. Audit Logging
  8. Error Messages
  9. Out of Scope

1. Startup Policy

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.

Disabled HTTP

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.

Upgrade & Migration

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.


2. Three-Layer Security Model

┌──────────────────────────────────────────────────────────┐
│  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.


3. Layer 0: PostgreSQL Privilege Check

3.1 Purpose

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.

3.2 Mechanism

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.

3.3 Managing access

HTTP access is opt-in and separate from general df access.

Granting 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.

Revoking access

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.

PUBLIC grant and upgrades

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.

3.4 Admin function protection

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.

3.5 Security-mode interaction

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.


3.6 Endpoint requests

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.


3.7 Explicit secret bindings

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.


4. Layer 1: IP Blocklist (SSRF protection)

4.1 Blocked IPv4 ranges

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

4.2 Blocked IPv6 ranges

Range Description
::/128 Unspecified
::1/128 Loopback
fe80::/10 Link-local
fc00::/7 Unique local (ULA)

4.3 IPv4-mapped IPv6 handling

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.

4.4 DNS rebinding prevention

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.

4.5 IP literals in URL

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.


5. Layer 2: Endpoint Allow-List

5.1 Configuring allowed domains

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.

5.2 Default Azure domains

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

5.3 Default exact-match domains

Matched exactly — subdomains and lookalikes are rejected.

Domain Purpose
api.github.com GitHub API

5.4 Test domains

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)

5.5 Bare IP rejection

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.

5.6 One parse, one URL

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.


6. Additional Hardening

6.1 Scheme restriction

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.

6.2 Redirect blocking

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.

6.3 Shared connection pool

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.


7. Audit Logging

Every HTTP attempt (allowed or blocked) is logged via ctx.trace_info with:

  • submitted_by — the role that called df.start() at the time the node was created (captured as current_user in the DSL and stored in FunctionNode)
  • 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.

7.1 URL redaction

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.vars secret storage; see Variables and secrets.

7.2 Response retention and body limits

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.

7.3 Table sinks

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.


8. Error Messages

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

9. Out of Scope

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.