feat(cubeapi): add reusable outbound URL security layer for callbacks and webhooks#1092
feat(cubeapi): add reusable outbound URL security layer for callbacks and webhooks#1092Lcos-000 wants to merge 1 commit into
Conversation
| let _ = dotenvy::dotenv(); | ||
| let cfg = config::Config::builder() | ||
| .add_source(config::Environment::default().separator("__")) | ||
| .add_source( |
There was a problem hiding this comment.
Suggestion: consider documenting the scope of try_parsing(true)
The diff adds .try_parsing(true) to the Environment source:
.add_source(
config::Environment::default()
.separator("__")
.try_parsing(true) // ← applied globally
.list_separator(",")
.with_list_parse_key("outbound_url_security.allowed_schemes"),
)try_parsing(true) tells config to parse all env var values as typed values ("true" → bool, "5000" → number), not just for allowed_schemes. This is global to every key in ServerConfig.
In practice this works because serde handles both string and numeric representations. However, the global scope is worth an inline comment explaining the reason (presumably to make list_separator + with_list_parse_key work together). Without one, a future maintainer might wonder why try_parsing was turned on globally, or accidentally break it when removing it thinking it's unused.
No functional defect observed.
| .await | ||
| .unwrap_err(); | ||
| assert!(matches!(err, OutboundUrlError::NonPublicAddress(_))); | ||
| } |
There was a problem hiding this comment.
Observation: read_body_with_limit and #[allow(dead_code)] — unreachable production code with a dependency cost
read_body_with_limit (and development(), webhook_default(), BodyLimitError::BodyTooLarge) are marked #[allow(dead_code)] because the only caller that would use them (webhook delivery) hasn't landed yet.
The functional impact is small, but it's worth noting that:
- This ships ~50 lines of production code as dead code
- The
streamfeature onreqwest(added inCargo.toml) is pulled in solely forread_body_with_limit'sbytes_stream()call — as visible in the Cargo.lock diff, it transitively addstokio-util,wasm-streams, andfutures-utilunder reqwest's dependency tree. These wouldn't be needed if the dead code were removed orcfg(test)-gated.
Consider gating with a feature flag (e.g. features = ["webhooks"]) rather than #[allow(dead_code)] on production items. Alternatively, the entire function and related types could be wrapped in #[cfg(feature = "webhooks")] for clarity about what's intended for future use vs. currently active.
Review: PR #1092 — feat(cubeapi): add reusable outbound URL security layer for callbacks and webhooksReview generated by AI. This is not a human review. OverviewThis PR adds a comprehensive SSRF-protection module ( The architecture is sound: a small public API surface ( I found three issues (one documentation, one correctness-adjacent, one code quality) and several minor observations. Findings1. [documentation] Inline doc comment uses wrong env var prefixFile: The doc comment on /// CUBE_API_OUTBOUND_URL_SECURITY__ALLOWED_SCHEMES=https
/// CUBE_API_OUTBOUND_URL_SECURITY__ALLOW_PRIVATE_IPS=false
/// CUBE_API_OUTBOUND_URL_SECURITY__RESOLVE_TIMEOUT_MS=5000
But the actual environment variable prefix is .add_source(
config::Environment::default()
.separator("__")
.try_parsing(true)
.list_separator(",")
.with_list_parse_key("outbound_url_security.allowed_schemes"),
)The user-facing docs in Fix: Remove the 2. [correctness] Misleading error variant when
|
Introduce a reusable outbound URL security primitive that validates, resolves, and pins callback/webhook URLs to prevent SSRF attacks. Key features: - Scheme and host validation (https only by default, no embedded credentials) - IPv4/IPv6 classification (loopback, private, link-local, multicast, etc.) - DNS resolution with timeout and address pinning via reqwest resolve() - Secure reqwest client builder with no redirects, no proxy, strict timeouts - Response body size limiting helper for safe callback/webhook handling - Integration with auth_callback_url startup validation and auth middleware Includes comprehensive unit tests and user-facing documentation. Autonomously-by: Kimi-K2.7-Code Signed-off-by: L-cos <3349888061@qq.com> Signed-off-by: Lcos-000 <3349888061@qq.com>
6e7adbe to
654c188
Compare
| Ok(()) | ||
| } | ||
|
|
||
| async fn resolve_with_timeout( |
There was a problem hiding this comment.
Doc comment uses wrong env var prefix
The inline rustdoc shows CUBE_API_OUTBOUND_URL_SECURITY__ALLOWED_SCHEMES etc., but the config builder in config/mod.rs uses config::Environment::default() with no prefix:
.add_source(
config::Environment::default()
.separator("__")
.try_parsing(true)
.list_separator(",")
.with_list_parse_key("outbound_url_security.allowed_schemes"),
)So the actual env var is just OUTBOUND_URL_SECURITY__ALLOWED_SCHEMES (no CUBE_API_). The Markdown docs (docs/guide/outbound-url-security.md) use the correct prefix. Update this doc comment to match reality.
| #[async_trait] | ||
| impl Resolver for StaticResolver { | ||
| async fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, std::io::Error> { | ||
| let addrs = self |
There was a problem hiding this comment.
Misleading error variant when port_or_known_default() returns None
If an operator configures ALLOWED_SCHEMES with a scheme that has no well-known default port in the url crate (e.g. "ws") and passes a URL without an explicit port, the error UnsupportedScheme(scheme) is misleading — the scheme was allowed, it just lacks a known default port.
Consider adding a dedicated error variant like NoDefaultPort(String), or document that user-configured schemes must have either a known default port or an explicit :port in the URL.
| .unwrap(); | ||
| assert_eq!(validated.resolved.len(), 1); | ||
| assert_eq!(validated.resolved[0].ip(), ipv4(1, 2, 3, 4)); | ||
| } |
There was a problem hiding this comment.
Redundant Content-Length parsing
response.content_length() (from http::Response) already reads and parses the Content-Length header, so the manual chain (headers().get() → .to_str() → .parse()) is dead code. In practice, if the manual parsing fails (e.g. non-UTF-8 header value), content_length() also fails because it parses the header as UTF-8 bytes internally.
Can be simplified to:
if let Some(len) = response.content_length() {
if len > max_bytes as u64 {
return Err(BodyLimitError::BodyTooLarge);
}
}
Summary
This PR introduces a reusable outbound URL security primitive for CubeAPI. Its immediate purpose is to harden
auth_callback_urlagainst SSRF and DNS rebinding; its longer-term purpose is to serve as the security foundation for upcoming webhook and event-delivery features.Background & Motivation
auth_callback_urlallows CubeAPI to delegate authentication to an external HTTP endpoint. Today the URL is accepted without validation, so a misconfigured or malicious value can target internal services such as127.0.0.1,169.254.169.254, or private subnets. This is a classic SSRF surface.Several Webhook PRs (e.g., lifecycle event delivery) are also in flight. They will share the same problem: how to safely dispatch HTTP requests to user-controlled URLs. Instead of baking ad-hoc checks into each feature, this PR provides a single, reviewable, testable security layer that any outbound-calling feature can reuse.
What This PR Does
Adds
CubeAPI/src/security/outbound_url.rs— a self-contained SSRF-protection module that covers:localhostblock)reqwest::ClientBuilder::resolvereqwest::Clientconstruction: no redirects, no proxy, explicit timeoutsread_body_with_limitAdds
OutboundUrlSecurityConfigtoServerConfigand loads it from environment variables.Integrates with
auth_callback_url:AppState::new(); startup fails fast on policy violationreqwest::ClientAdds user documentation:
docs/zh/guide/outbound-url-security.mddocs/guide/outbound-url-security.mdThreat Model & Key Protections
127.0.0.1,::1,localhost, and IPv4-mapped loopback10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10, etc.169.254.0.0/16including169.254.169.254reqwest::resolvehttpsonly;httpmust be explicitly alloweduser:pass@read_body_with_limitcaps response body sizeDesign Decisions
Use
url.host()instead ofurl.host_str()for IP detectionhost_str()returns IPv6 literals with square brackets (e.g.,"[::ffff:127.0.0.1]"), which cannot be parsed directly asIpAddr. Usingurl.host()avoids this platform/version-sensitive parsing issue entirely.Return a
reqwest::Client, not a raw responseThe security layer validates the URL and builds a hardened client, then hands control back to the caller. This keeps the primitive focused and reusable for callbacks, webhooks, log shippers, or any other outbound caller.
Disable proxy by default
In WSL test environments we observed that
reqwest::Client::new()routes traffic throughHTTP_PROXY, which can return 502 for local test servers and is undesirable for callback/webhook traffic. The hardened client explicitly calls.no_proxy().Startup-time validation for
auth_callback_urlFailing at startup is safer than failing on the first request. It also makes misconfigurations obvious during deployment.
How Webhook Modules Can Reuse This
webhook_default()uses the same strict defaults as production:httpsonly and no private addresses.Configuration
Environment variables use
__as the nested separator:For local development only:
Testing
cargo fmt -- --check cargo clippy --package cube-api --all-targets cargo test --package cube-apiResults: 130 passed; 0 failed.
Additional deployment-level smoke tests were performed in WSL:
AUTH_CALLBACK_URL=http://127.0.0.1:9999/authwith default policy → CubeAPI exits with an SSRF policy error.OUTBOUND_URL_SECURITY__ALLOW_PRIVATE_IPS=trueand a local Python HTTP server as callback → CubeAPI starts and the callback server receives the expectedX-Request-Path,X-Request-Method, andX-API-Keyheaders.Validation Examples
Rejected by default:
http://example.com/hookhttps://127.0.0.1/hookhttps://localhost/hookhttps://10.0.0.1/hookhttps://169.254.169.254/hookhttps://[::ffff:127.0.0.1]/hookhttps://user:pass@example.com/hookAccepted by default:
https://example.com/hookhttps://1.2.3.4/hookhttps://example.com:8443/hookChecklist
rustfmtstylecargo clippy --package cube-api --all-targetspassesFuture Work
HttpLoggerwebhook log backend stub.Related to #642