Skip to content

feat(security): add bounded outbound fetch API - #113

Merged
seonghobae merged 2 commits into
feat/issue-79-destination-policyfrom
feat/outbound-fetch-api
Aug 26, 2026
Merged

feat(security): add bounded outbound fetch API#113
seonghobae merged 2 commits into
feat/issue-79-destination-policyfrom
feat/outbound-fetch-api

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an admin-authenticated POST /api/outbound/fetch boundary for reusable HTTPS document retrieval
  • reuse DestinationPolicy and pinned DNS at every hop with no ambient proxy and at most three redirects
  • bound accepted document content types, response bytes, timeout, and sanitize stable error codes

Contract

Request: {"url":"https://example.com/privacy","max_bytes":524288}

Success: {"status":200,"content_type":"text/html; charset=utf-8","final_url":"https://example.com/privacy","body_base64":"...","redirects":0}

Verification

  • cargo fmt --check
  • cargo test (164 unit, 6 binary, 1 fuzz invariant; all pass)

Stacked on #96 so the API owns no duplicate DNS or destination-policy logic.


Open in Devin Review

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 66f98bc2-2bcb-4bf1-8fd2-1bb752a6c44d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae
seonghobae merged commit 55d8025 into feat/issue-79-destination-policy Aug 26, 2026
6 of 7 checks passed
@seonghobae
seonghobae deleted the feat/outbound-fetch-api branch August 26, 2026 13:04

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment thread src/lib.rs
Comment on lines +766 to +788
for redirects in 0..=OUTBOUND_FETCH_MAX_REDIRECTS {
let decision = state.resolve_outbound(url.as_str()).await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"destination_denied",
"destination policy denied the URL",
)
})?;
// A request-local pin board prevents a concurrent evaluation of the same
// hostname from replacing the addresses between policy and connect.
let request_pins = Arc::new(destination::DestinationPins::default());
request_pins.record(&decision.host, &decision.ips);
let request_http = outbound_http_client(request_pins);
let response = request_http.get(url.clone()).send().await.map_err(|_| {
(
StatusCode::BAD_GATEWAY,
"upstream_request_failed",
"upstream request failed",
)
})?;

if response.status().is_redirection() {
if redirects == OUTBOUND_FETCH_MAX_REDIRECTS {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Redirect bound is correct (no off-by-one)

for redirects in 0..=OUTBOUND_FETCH_MAX_REDIRECTS follows at most 3 redirects across up to 4 requests; a 3xx at the final index returns too_many_redirects. Matches the documented bound, and the trailing unreachable! is genuinely unreachable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/lib.rs
Comment on lines +767 to +778
let decision = state.resolve_outbound(url.as_str()).await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"destination_denied",
"destination policy denied the URL",
)
})?;
// A request-local pin board prevents a concurrent evaluation of the same
// hostname from replacing the addresses between policy and connect.
let request_pins = Arc::new(destination::DestinationPins::default());
request_pins.record(&decision.host, &decision.ips);
let request_http = outbound_http_client(request_pins);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: resolve_outbound writes shared pin board unnecessarily

resolve_outbound records into the shared state.pins, but the fetch handler connects through a request-local request_pins and a fresh client. The shared write is an unused side effect that can evict other pins under the 4096 cap. Benign, but at odds with the request-local intent noted at lib.rs.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/lib.rs
Comment on lines +704 to +715
async fn outbound_fetch(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<OutboundFetchRequest>,
) -> Response {
if !admin_authorized(&state, &headers) {
return outbound_fetch_error(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing or invalid X-Admin-Token",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Malformed JSON bypasses stable error envelope

Json<OutboundFetchRequest> is extracted before the handler runs, so a malformed body returns axum's default plain-text 400 rather than the documented {code, error} envelope, and before the auth check. Consistent with other endpoints, but the fetch contract promises stable error fields.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

seonghobae added a commit that referenced this pull request Aug 26, 2026
* feat(security): fail-closed destination policy for outbound HTTP

One DestinationPolicy mediates gateway upstreams, threat-intel fetches,
Clearfolio, SOC LLM, and the Coraza sidecar URL. Private, loopback,
link-local, CGNAT, and metadata classes are denied unless
DESTINATION_ALLOWLIST (or loopback development) permits them;
DESTINATION_DENYLIST wins. Clients ignore ambient HTTP proxies and do
not follow redirects.

Refs #79.

* docs: record PR #96 in the product-technical gap baseline

* feat(security): harden destination policy per-IP CIDR and readiness order

CIDR allowlist matches apply per resolved address, authorize non-default
ports, and reject prefixes outside the address-family width. IPv6
site-local is a denied class. Hostnames that merely contain 0x are not
hex IP literals. AppState constructors default to production policy;
seeded fixtures opt into development. Blocking DNS runs on spawn_blocking
with a timeout. Persistence and destination-list validation complete
before the readiness line.

* feat(security): pin outbound HTTP to evaluated destination addresses

After destination policy allows a host, the reqwest client resolves
only those IPs so a rebinding answer cannot reach loopback, private,
or metadata classes. Host and SNI stay on the original name.
Unpinned hostnames fail closed instead of falling back to OS DNS.

* feat(waf): evaluate live gateway transactions with in-process libcoraza

Issue #86 remainder: dlopen operator-supplied libcoraza and drive the C ABI
on each /gateway request. Missing library or empty ruleset fail closed before
bind. CI stays hermetic with a fixture cdylib that exports the same symbols.

* docs: record PR #97 in the product-technical gap baseline

* feat(waf): forward bounded client headers into in-process libcoraza

In-process transactions now receive the same forwarded-header allowlist as
the sidecar path (host, user-agent, accept, content-type, referer, origin,
x-requested-with, x-forwarded-for, x-real-ip, cookie — never Authorization;
32 headers / 8 KiB caps enforced by proven_engine::engine_forwarded_headers).
Each header crosses the C ABI via coraza_add_request_header before
process_request_headers, so CRS rules that inspect headers evaluate real
client input instead of a synthetic Host only.

Brings in the PR #95 sidecar hardening via merge so both engines share one
allowlist implementation and one status/bound contract.

Behavioral header-battery evidence lands with the issue-11 battery fixture
(PR #110); this slice ships the plumbing and keeps the stub contract
unchanged.

* feat(store): require PostgreSQL as the production control plane (#98)

* feat(store): require PostgreSQL as the production control plane

Non-loopback binds fail closed without CONTROL_PLANE_DATABASE_URL.
Migrations create 3NF two-word tables with default-deny RLS. Snapshot
persist commits policy rows and audit records in one transaction.
Loopback still uses the JSON file or memory adapter.

* feat(store): transactional outbox and leased workers

Issue #81 first slice on the PostgreSQL control plane. Security events
append with an outbox row in one transaction instead of rewriting the
snapshot. Workers claim with SKIP LOCKED, retry, dead-letter, and
record unique receipts. Stdout SIEM is at-least-once; the receipt is
the exactly-once ack. Also deterministic ORDER BY on postgres loads
(still-valid #98 finding). Do not re-implement the postgres gate.

* feat(store): rustls for production PostgreSQL sslmode=require (#100)

* feat(store): rustls for production PostgreSQL sslmode=require

Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with
rustls and Mozilla roots; certificates are always verified. allow/prefer
are still rejected so the process cannot silently drop to plaintext.
Live test against plaintext CI postgres proves fail-closed. Do not
re-implement the postgres gate or the outbox.

* docs: record PR #100 in the product-technical gap baseline

* fix(store): rewrite verify-full sslmode for tokio-postgres 0.7

Still-valid #100 Devin finding. tokio-postgres 0.7 only parses
disable/prefer/require. Map verify-ca/verify-full to require before
connect; rustls still verifies certificates. Password query-lookalikes
are left untouched.

* feat(store): bound outbox listing and prune processed rows (#101)

* feat(store): bound outbox listing and prune processed rows

Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT
rows (dead letters, then pending, then leased, then processed).
Processed outbox_message rows prune to that cap; receipts and dead
letters stay. Do not re-implement the outbox, postgres gate, or rustls.

* docs: record PR #101 in the product-technical gap baseline

* fix(store): prune processed outbox to EVENT_LIMIT on save and ack

Still-valid #101 Devin finding. Snapshot save and worker ack used
LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the
configured cap on PostgresPlane so all three paths retain the same
processed-row bound. Receipts and dead letters stay.

* feat(store): logical backup and isolated restore drill (#102)

* feat(store): logical backup and isolated restore drill

Issue #80 remainder stacked on #101. GET /api/backup exports a hashed
tenant snapshot; POST /api/backup restores after schema and payload-hash
checks; POST /api/backup/drill restores into an isolated tenant, compares
unmasked invariants, and drops the drill rows. Declared RPO is last
successful export; declared RTO is 60s. File/memory adapters report
backup=disabled. Do not re-implement rustls, outbox, or retention.

* docs: record PR #102 in the product-technical gap baseline

* feat(store): non-owner PostgreSQL runtime role after migrate (#103)

* feat(store): non-owner PostgreSQL runtime role after migrate

Still-valid #98 finding. CI connects as a superuser, which bypasses
FORCE RLS. Migrations stay on the login role, then SET ROLE
wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing
tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied.
Do not re-implement rustls, outbox, retention, or backup/restore.

* docs: record PR #103 in the product-technical gap baseline

* fix(store): restore logical backups across role-only schema versions

v3 only provisions wardnet_runtime and does not change table shape.
verify() accepts schema 2 through the current migration version so a
role-only upgrade cannot void the last pre-upgrade logical backup.

* feat(store): HASH-partition security_event by tenant (#104)

* feat(store): HASH-partition security_event by tenant

Convert unpartitioned security_event to PARTITION BY HASH (tenant_id)
with eight children under pg_advisory_lock. Rows keep unmasked client
IPs and paths. /healthz.event_partitions reports the child count.
Logical restore still accepts schema 2 through the current version.

* docs: record PR #104 in the product-technical gap baseline

* feat(security): add bounded outbound fetch API (#113)

* feat(security): add bounded outbound fetch API

* fix(security): isolate fetch DNS pins per request

* feat(security): route browser DNS and HTTPS through Wardnet (#116)

* feat(security): add bounded outbound fetch API

* feat(security): route browser DNS and HTTPS through Wardnet

* fix(security): isolate fetch DNS pins per request

* fix(dns): bound concurrent UDP query handling

* fix(security): close destination policy review gaps

* fix(runtime): make worker shutdown durable
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant