feat(v1.1.4): outbound HTTP SDK + cron triggers
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
457
crates/manager-core/src/ssrf.rs
Normal file
457
crates/manager-core/src/ssrf.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
//! SSRF deny-list — the load-bearing security mechanism behind the
|
||||
//! v1.1.4 `http::*` SDK.
|
||||
//!
|
||||
//! The policy is applied to the **resolved IP address**, not the
|
||||
//! hostname. That is the DNS-rebinding defense: a hostname that
|
||||
//! resolves to a public IP at lookup time and a private IP at connect
|
||||
//! time is not exploitable, because reqwest re-runs every connection
|
||||
//! (including post-redirect hops) through [`SsrfResolver`], which
|
||||
//! filters the address list before the socket is opened.
|
||||
//!
|
||||
//! [`SsrfPolicy::check`] returns a CIDR-*category* reason on denial
|
||||
//! (e.g. `"loopback"`, `"private"`) — never the IP itself, so the
|
||||
//! script-visible error can't be used to map the internal network.
|
||||
//!
|
||||
//! `PICLOUD_HTTP_ALLOW_PRIVATE=true` flips `allow_private`, which
|
||||
//! short-circuits every check to allow. That is dev/test-only and the
|
||||
//! binary logs a startup warning when it's set.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
/// Decision policy for a single resolved IP. Cheap to clone (one bool).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SsrfPolicy {
|
||||
/// When true, every address is allowed — the entire deny-list is
|
||||
/// disabled. Set from `PICLOUD_HTTP_ALLOW_PRIVATE`. Dev/test only.
|
||||
pub allow_private: bool,
|
||||
}
|
||||
|
||||
impl SsrfPolicy {
|
||||
#[must_use]
|
||||
pub const fn new(allow_private: bool) -> Self {
|
||||
Self { allow_private }
|
||||
}
|
||||
|
||||
/// `Ok(())` if the IP may be connected to; `Err(reason)` with a
|
||||
/// CIDR-category label otherwise. The reason is safe to surface to
|
||||
/// a script — it never contains the address.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the deny reason when `ip` falls in a blocked range and
|
||||
/// `allow_private` is false.
|
||||
pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
|
||||
if self.allow_private {
|
||||
return Ok(());
|
||||
}
|
||||
match ip {
|
||||
IpAddr::V4(v4) => check_v4(v4),
|
||||
IpAddr::V6(v6) => check_v6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_allowed(&self, ip: IpAddr) -> bool {
|
||||
self.check(ip).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv4 deny-list. Order doesn't matter (ranges are disjoint by
|
||||
/// construction); first match wins for the reason label.
|
||||
// Several arms share a reason ("private") for distinct CIDRs — keeping
|
||||
// them separate documents each blocked range explicitly.
|
||||
#[allow(clippy::match_same_arms)]
|
||||
fn check_v4(ip: Ipv4Addr) -> Result<(), &'static str> {
|
||||
let o = ip.octets();
|
||||
match o {
|
||||
[127, ..] => Err("loopback"),
|
||||
[0, ..] => Err("unspecified"), // 0.0.0.0/8 "this network"
|
||||
[10, ..] => Err("private"),
|
||||
[172, b, ..] if (16..=31).contains(&b) => Err("private"),
|
||||
[192, 168, ..] => Err("private"),
|
||||
[169, 254, ..] => Err("link-local"), // includes cloud metadata 169.254.169.254
|
||||
[100, b, ..] if (64..=127).contains(&b) => Err("carrier-grade-nat"),
|
||||
[224..=239, ..] => Err("multicast"),
|
||||
[240..=255, ..] => Err("reserved"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv6 deny-list. IPv4-mapped addresses (`::ffff:0:0/96`) re-run the
|
||||
/// v4 deny-list against the embedded address.
|
||||
fn check_v6(ip: Ipv6Addr) -> Result<(), &'static str> {
|
||||
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
|
||||
// so a mapped private/loopback address can't sneak through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return check_v4(v4);
|
||||
}
|
||||
if ip == Ipv6Addr::LOCALHOST {
|
||||
return Err("loopback");
|
||||
}
|
||||
if ip == Ipv6Addr::UNSPECIFIED {
|
||||
return Err("unspecified");
|
||||
}
|
||||
let seg0 = ip.segments()[0];
|
||||
if seg0 & 0xffc0 == 0xfe80 {
|
||||
return Err("link-local"); // fe80::/10
|
||||
}
|
||||
if seg0 & 0xfe00 == 0xfc00 {
|
||||
return Err("unique-local"); // fc00::/7
|
||||
}
|
||||
if seg0 & 0xff00 == 0xff00 {
|
||||
return Err("multicast"); // ff00::/8
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marker error returned by the resolver when *every* resolved address
|
||||
/// for a host was denied. reqwest wraps this into a connect error; the
|
||||
/// `http_service` impl walks the source chain for the
|
||||
/// `"blocked by SSRF policy:"` prefix to surface a clean
|
||||
/// [`crate::http_service::HttpError::Ssrf`] instead of a generic DNS
|
||||
/// failure. Keeping the reason a category label means no IP leaks.
|
||||
#[derive(Debug)]
|
||||
struct SsrfBlocked {
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SsrfBlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "blocked by SSRF policy: {}", self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfBlocked {}
|
||||
|
||||
/// Prefix the resolver embeds in its error and the impl scans for.
|
||||
pub const SSRF_BLOCK_PREFIX: &str = "blocked by SSRF policy: ";
|
||||
|
||||
/// Pluggable host→addresses lookup. Production uses the system
|
||||
/// resolver; tests inject a closure (e.g. to simulate DNS rebinding —
|
||||
/// a different address on a later call).
|
||||
pub type LookupFn = Arc<
|
||||
dyn Fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
fn system_lookup(
|
||||
host: String,
|
||||
) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
|
||||
Box::pin(async move {
|
||||
// Port 0 — reqwest overrides it with the real target port.
|
||||
Ok(tokio::net::lookup_host((host.as_str(), 0u16))
|
||||
.await?
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// reqwest DNS resolver that delegates to the system resolver, then
|
||||
/// filters the address list through [`SsrfPolicy`]. Plugged in via
|
||||
/// `ClientBuilder::dns_resolver`, so it runs at the actual connection
|
||||
/// point — including for every redirect hop. This is the DNS-rebinding
|
||||
/// defense: filtering happens at connect time, not at URL-parse time.
|
||||
#[derive(Clone)]
|
||||
pub struct SsrfResolver {
|
||||
policy: SsrfPolicy,
|
||||
lookup: LookupFn,
|
||||
}
|
||||
|
||||
impl SsrfResolver {
|
||||
#[must_use]
|
||||
pub fn new(policy: SsrfPolicy) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
lookup: Arc::new(system_lookup),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct with an injected lookup (tests only).
|
||||
#[must_use]
|
||||
pub fn with_lookup(policy: SsrfPolicy, lookup: LookupFn) -> Self {
|
||||
Self { policy, lookup }
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for SsrfResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let policy = self.policy;
|
||||
let lookup = self.lookup.clone();
|
||||
let host = name.as_str().to_string();
|
||||
Box::pin(async move {
|
||||
let resolved: Vec<SocketAddr> = lookup(host)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
|
||||
|
||||
// Empty resolution → genuine DNS miss; let reqwest surface
|
||||
// it as a normal "no addresses" error.
|
||||
if resolved.is_empty() {
|
||||
let addrs: Addrs = Box::new(std::iter::empty());
|
||||
return Ok(addrs);
|
||||
}
|
||||
|
||||
let mut allowed: Vec<SocketAddr> = Vec::with_capacity(resolved.len());
|
||||
let mut last_reason: &'static str = "denied";
|
||||
for sa in resolved {
|
||||
match policy.check(sa.ip()) {
|
||||
Ok(()) => allowed.push(sa),
|
||||
Err(reason) => last_reason = reason,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution returned addresses but the policy denied them
|
||||
// all → fail with the SSRF marker so the impl can report a
|
||||
// policy block (not a generic DNS error).
|
||||
if allowed.is_empty() {
|
||||
let err: Box<dyn std::error::Error + Send + Sync> = Box::new(SsrfBlocked {
|
||||
reason: last_reason,
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let addrs: Addrs = Box::new(allowed.into_iter());
|
||||
Ok(addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the resolver. reqwest's `dns_resolver` is generic over a
|
||||
/// concrete `R: Resolve` (it stores `Arc<R>`), so this returns the
|
||||
/// concrete `Arc<SsrfResolver>` rather than a trait object.
|
||||
#[must_use]
|
||||
pub fn resolver(policy: SsrfPolicy) -> Arc<SsrfResolver> {
|
||||
Arc::new(SsrfResolver::new(policy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn denied(ip: &str) -> &'static str {
|
||||
let policy = SsrfPolicy::new(false);
|
||||
policy
|
||||
.check(IpAddr::from_str(ip).unwrap())
|
||||
.expect_err(&format!("{ip} should be denied"))
|
||||
}
|
||||
|
||||
fn allowed(ip: &str) {
|
||||
let policy = SsrfPolicy::new(false);
|
||||
policy
|
||||
.check(IpAddr::from_str(ip).unwrap())
|
||||
.unwrap_or_else(|r| panic!("{ip} should be allowed, denied as {r}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv4_loopback() {
|
||||
assert_eq!(denied("127.0.0.1"), "loopback");
|
||||
assert_eq!(denied("127.1.2.3"), "loopback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv4_unspecified() {
|
||||
assert_eq!(denied("0.0.0.0"), "unspecified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_rfc1918_private() {
|
||||
assert_eq!(denied("10.0.0.1"), "private");
|
||||
assert_eq!(denied("10.255.255.255"), "private");
|
||||
assert_eq!(denied("172.16.0.1"), "private");
|
||||
assert_eq!(denied("172.31.255.255"), "private");
|
||||
assert_eq!(denied("192.168.0.1"), "private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_172_outside_private_range() {
|
||||
// 172.15.x and 172.32.x are public — only 172.16.0.0/12 is private.
|
||||
allowed("172.15.0.1");
|
||||
allowed("172.32.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_link_local_and_cloud_metadata() {
|
||||
assert_eq!(denied("169.254.0.1"), "link-local");
|
||||
// The cloud metadata endpoint is the canonical SSRF target.
|
||||
assert_eq!(denied("169.254.169.254"), "link-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_carrier_grade_nat() {
|
||||
assert_eq!(denied("100.64.0.1"), "carrier-grade-nat");
|
||||
assert_eq!(denied("100.127.255.255"), "carrier-grade-nat");
|
||||
// 100.63.x and 100.128.x are outside 100.64.0.0/10.
|
||||
allowed("100.63.0.1");
|
||||
allowed("100.128.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_multicast_and_reserved() {
|
||||
assert_eq!(denied("224.0.0.1"), "multicast");
|
||||
assert_eq!(denied("239.255.255.255"), "multicast");
|
||||
assert_eq!(denied("240.0.0.1"), "reserved");
|
||||
assert_eq!(denied("255.255.255.255"), "reserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv4() {
|
||||
allowed("1.1.1.1");
|
||||
allowed("8.8.8.8");
|
||||
allowed("93.184.216.34"); // example.com
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_loopback() {
|
||||
assert_eq!(denied("::1"), "loopback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_unspecified() {
|
||||
assert_eq!(denied("::"), "unspecified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_link_local() {
|
||||
assert_eq!(denied("fe80::1"), "link-local");
|
||||
assert_eq!(denied("febf:ffff::1"), "link-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_unique_local() {
|
||||
assert_eq!(denied("fc00::1"), "unique-local");
|
||||
assert_eq!(denied("fd12:3456::1"), "unique-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_multicast() {
|
||||
assert_eq!(denied("ff00::1"), "multicast");
|
||||
assert_eq!(denied("ff02::1"), "multicast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv6() {
|
||||
allowed("2606:4700:4700::1111"); // cloudflare
|
||||
allowed("2001:4860:4860::8888"); // google
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_mapped_ipv6_rechecks_embedded_address() {
|
||||
// ::ffff:127.0.0.1 must be denied via the embedded-v4 re-check.
|
||||
assert_eq!(denied("::ffff:127.0.0.1"), "loopback");
|
||||
assert_eq!(denied("::ffff:10.0.0.1"), "private");
|
||||
assert_eq!(denied("::ffff:169.254.169.254"), "link-local");
|
||||
// A mapped *public* address stays allowed.
|
||||
allowed("::ffff:1.1.1.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_private_disables_all_denials() {
|
||||
let policy = SsrfPolicy::new(true);
|
||||
for ip in ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"] {
|
||||
assert!(policy.is_allowed(IpAddr::from_str(ip).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolver-path tests (the connect-time filter) ---
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn name(s: &str) -> Name {
|
||||
Name::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn fixed_lookup(addrs: Vec<SocketAddr>) -> LookupFn {
|
||||
Arc::new(move |_host| {
|
||||
let addrs = addrs.clone();
|
||||
Box::pin(async move { Ok(addrs) })
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_returns_only_allowed_addresses() {
|
||||
// A host resolving to one public + one private IP yields only
|
||||
// the public one to reqwest.
|
||||
let public: SocketAddr = "1.1.1.1:0".parse().unwrap();
|
||||
let private: SocketAddr = "10.0.0.1:0".parse().unwrap();
|
||||
let resolver =
|
||||
SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![public, private]));
|
||||
let got: Vec<SocketAddr> = resolver
|
||||
.resolve(name("mixed.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(got, vec![public]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_all_denied_fails_with_ssrf_marker() {
|
||||
// A host resolving to ONLY private IPs fails with the SSRF
|
||||
// marker (not a generic empty/DNS result).
|
||||
let resolver = SsrfResolver::with_lookup(
|
||||
SsrfPolicy::new(false),
|
||||
fixed_lookup(vec![
|
||||
"10.0.0.1:0".parse().unwrap(),
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
]),
|
||||
);
|
||||
let Err(err) = resolver.resolve(name("internal.example")).await else {
|
||||
panic!("all-denied resolution should error");
|
||||
};
|
||||
assert!(
|
||||
err.to_string().starts_with(SSRF_BLOCK_PREFIX),
|
||||
"expected SSRF marker, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_dns_rebinding_second_resolution_denied() {
|
||||
// Simulate rebinding: public IP on the first lookup, private on
|
||||
// the second. The connect-time filter denies the second.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let calls2 = calls.clone();
|
||||
let lookup: LookupFn = Arc::new(move |_host| {
|
||||
let n = calls2.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
let addr: SocketAddr = if n == 0 {
|
||||
"1.1.1.1:0".parse().unwrap()
|
||||
} else {
|
||||
"127.0.0.1:0".parse().unwrap()
|
||||
};
|
||||
Ok(vec![addr])
|
||||
})
|
||||
});
|
||||
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), lookup);
|
||||
|
||||
// First resolution: public → allowed.
|
||||
let first: Vec<SocketAddr> = resolver
|
||||
.resolve(name("rebind.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(first, vec!["1.1.1.1:0".parse::<SocketAddr>().unwrap()]);
|
||||
|
||||
// Second resolution: rebinding returns loopback → denied.
|
||||
let Err(err) = resolver.resolve(name("rebind.example")).await else {
|
||||
panic!("rebound private address must be denied");
|
||||
};
|
||||
assert!(err.to_string().contains("loopback"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_empty_resolution_is_not_ssrf() {
|
||||
// Genuine DNS miss (no addresses) returns an empty iterator,
|
||||
// NOT the SSRF marker — reqwest surfaces a normal DNS error.
|
||||
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![]));
|
||||
let got: Vec<SocketAddr> = resolver
|
||||
.resolve(name("nxdomain.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user