Files
PiCloud/crates/manager-core/src/http_service.rs
MechaCat02 10b5f655d5 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>
2026-06-03 20:23:18 +02:00

794 lines
28 KiB
Rust

//! `HttpServiceImpl` — reqwest-backed outbound HTTP for the v1.1.4
//! `http::*` SDK.
//!
//! Mirrors the v1.1.1+ stateful-service shape (`KvServiceImpl`):
//! script-as-gate authz (`AppHttpRequest`, skipped when
//! `cx.principal` is `None`), with the backend talking to the network
//! instead of Postgres. The reqwest client is built once at startup
//! with the [`crate::ssrf::SsrfResolver`] wired in via
//! `dns_resolver`, so the SSRF deny-list applies at every connection —
//! including each redirect hop, since redirects are followed manually
//! through the same client.
//!
//! Layering vs the raw client:
//! 1. URL validation: scheme must be http/https; ports 22/25/465/587
//! are blocked. (IP-level filtering is the resolver's job.)
//! 2. Body-size caps on both request and response (stream-with-cap on
//! the response, checking `Content-Length` first).
//! 3. Total-request timeout (default 30s, max 60s) on top of the
//! client's 10s connect timeout.
//! 4. Default `User-Agent` unless the caller set one.
//!
//! Bodies/headers are never logged (PII): only url + status + duration
//! at debug level.
use std::collections::BTreeMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION, USER_AGENT};
use reqwest::{Client, Method, StatusCode};
use crate::authz::{self, AuthzRepo, Capability};
use crate::ssrf::{self, SsrfPolicy, SSRF_BLOCK_PREFIX};
/// Default per-request timeout (ms) when the script omits `timeout_ms`.
pub const DEFAULT_TIMEOUT_MS: u32 = 30_000;
/// Hard ceiling on the per-request timeout. Values above this are
/// rejected by the bridge (not silently clamped).
pub const MAX_TIMEOUT_MS: u32 = 60_000;
/// Default redirect cap.
pub const DEFAULT_MAX_REDIRECTS: u32 = 5;
/// Hard ceiling on redirects.
pub const MAX_REDIRECTS_CEILING: u32 = 10;
/// 10 MB default body cap on both directions.
const DEFAULT_BODY_LIMIT_BYTES: usize = 10 * 1024 * 1024;
/// DNS + connect + TLS hard cap.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Outbound-HTTP tunables. Env-overridable following the same pattern
/// as `TriggerConfig::from_env`.
#[derive(Debug, Clone, Copy)]
pub struct HttpConfig {
/// Disables the SSRF deny-list entirely. Dev/test only — the binary
/// logs a startup warning when this is set.
pub allow_private: bool,
pub max_request_body_bytes: usize,
pub max_response_body_bytes: usize,
}
impl HttpConfig {
#[must_use]
pub const fn conservative() -> Self {
Self {
allow_private: false,
max_request_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
max_response_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
}
}
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = env::var("PICLOUD_HTTP_ALLOW_PRIVATE") {
c.allow_private =
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes");
}
load_usize(
&mut c.max_request_body_bytes,
"PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES",
);
load_usize(
&mut c.max_response_body_bytes,
"PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES",
);
c
}
}
impl Default for HttpConfig {
fn default() -> Self {
Self::conservative()
}
}
fn load_usize(dst: &mut usize, key: &str) {
if let Ok(v) = env::var(key) {
match v.parse::<usize>() {
Ok(n) => *dst = n,
Err(e) => {
tracing::warn!(env = key, error = %e, "ignoring invalid http-config value");
}
}
}
}
pub struct HttpServiceImpl {
client: Client,
authz: Arc<dyn AuthzRepo>,
config: HttpConfig,
/// Same policy wired into the DNS resolver. Held here too because
/// reqwest only routes *hostnames* through the custom resolver — a
/// URL with a **literal IP** host bypasses it, so literal IPs are
/// checked directly at URL-validation time.
policy: SsrfPolicy,
}
impl HttpServiceImpl {
/// Build the service, constructing the reqwest client with the SSRF
/// resolver. Redirects are followed manually (so per-request limits
/// are honored and every hop re-resolves through the SSRF
/// resolver), hence `redirect(Policy::none())`.
///
/// # Panics
///
/// Panics if the reqwest client fails to build — this is a
/// startup-time invariant, not a runtime path.
#[must_use]
pub fn new(config: HttpConfig, authz: Arc<dyn AuthzRepo>) -> Self {
let policy = SsrfPolicy::new(config.allow_private);
let client = Client::builder()
.dns_resolver(ssrf::resolver(policy))
.connect_timeout(CONNECT_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("build outbound http client");
Self {
client,
authz,
config,
policy,
}
}
async fn check_request(&self, cx: &SdkCallCx) -> Result<(), HttpError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppHttpRequest(cx.app_id),
)
.await
.map_err(|_| HttpError::Forbidden)?;
}
Ok(())
}
}
#[async_trait]
impl HttpService for HttpServiceImpl {
async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError> {
self.check_request(cx).await?;
// Request body cap.
if let Some(ref body) = req.body {
if body.len() > self.config.max_request_body_bytes {
return Err(HttpError::BodyTooLarge("request"));
}
}
let timeout = Duration::from_millis(u64::from(req.timeout_ms.min(MAX_TIMEOUT_MS)));
let started = std::time::Instant::now();
let url_for_log = req.url.clone();
// Whole-request budget (DNS + connect + TLS + all redirect hops
// + body read). Connect alone is further bounded by the
// client's CONNECT_TIMEOUT.
let outcome = match tokio::time::timeout(timeout, self.run(req)).await {
Ok(r) => r,
Err(_) => Err(HttpError::Timeout),
};
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match &outcome {
Ok(resp) => tracing::debug!(
url = %url_for_log,
status = resp.status,
duration_ms,
"outbound http"
),
Err(err) => tracing::debug!(
url = %url_for_log,
error = %err,
duration_ms,
"outbound http failed"
),
}
outcome
}
}
impl HttpServiceImpl {
/// Core request path: validate, build headers, follow redirects
/// manually, read the response body with a cap.
async fn run(&self, req: HttpRequest) -> Result<HttpResponse, HttpError> {
let method = Method::from_bytes(req.method.as_bytes())
.map_err(|_| HttpError::Backend(format!("invalid method: {}", req.method)))?;
let mut current = url::Url::parse(&req.url)
.map_err(|e| HttpError::InvalidUrl(format!("{}: {e}", req.url)))?;
validate_url(&current, self.policy)?;
let mut header_map = build_headers(&req, &current)?;
let mut method = method;
let mut body = req.body.clone();
let mut redirects: u32 = 0;
let max_redirects = req.max_redirects.min(MAX_REDIRECTS_CEILING);
loop {
// Re-validate scheme/port (and literal-IP SSRF) on each hop.
// Hostname IP filtering is the resolver's job and runs
// automatically at connect time.
validate_url(&current, self.policy)?;
let mut rb = self.client.request(method.clone(), current.clone());
rb = rb.headers(header_map.clone());
if let Some(ref b) = body {
rb = rb.body(b.clone());
}
let resp = rb.send().await.map_err(map_reqwest_err)?;
let status = resp.status();
if req.follow_redirects && is_redirect(status) {
if let Some(loc) = resp.headers().get(LOCATION) {
if redirects >= max_redirects {
return Err(HttpError::Backend(format!(
"too many redirects (max {max_redirects})"
)));
}
redirects += 1;
let loc_str = loc.to_str().map_err(|_| {
HttpError::Backend("redirect Location not valid UTF-8".into())
})?;
current = current
.join(loc_str)
.map_err(|e| HttpError::InvalidUrl(format!("redirect target: {e}")))?;
// 303 always → GET; 301/302 historically downgrade
// POST→GET (matches browsers). 307/308 preserve.
if matches!(status.as_u16(), 301..=303) {
method = Method::GET;
body = None;
header_map.remove(CONTENT_TYPE);
}
continue;
}
}
return self.read_capped(resp).await;
}
}
async fn read_capped(&self, resp: reqwest::Response) -> Result<HttpResponse, HttpError> {
let status = resp.status().as_u16();
let mut headers = BTreeMap::new();
for (name, value) in resp.headers() {
// Header names lowercased per the documented response shape.
headers.insert(
name.as_str().to_ascii_lowercase(),
value.to_str().unwrap_or("").to_string(),
);
}
let cap = self.config.max_response_body_bytes;
if let Some(len) = resp.content_length() {
if len > cap as u64 {
return Err(HttpError::BodyTooLarge("response"));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut resp = resp;
while let Some(chunk) = resp.chunk().await.map_err(map_reqwest_err)? {
if buf.len() + chunk.len() > cap {
return Err(HttpError::BodyTooLarge("response"));
}
buf.extend_from_slice(&chunk);
}
let body_raw = String::from_utf8_lossy(&buf).into_owned();
Ok(HttpResponse {
status,
headers,
body_raw,
})
}
}
/// http/https only; block the SSH + SMTP ports; apply the SSRF policy
/// to **literal-IP** hosts (hostnames are filtered by the DNS resolver
/// at connect time, but literal IPs never reach the resolver).
fn validate_url(url: &url::Url, policy: SsrfPolicy) -> Result<(), HttpError> {
match url.scheme() {
"http" | "https" => {}
other => return Err(HttpError::BlockedScheme(other.to_string())),
}
match url.host() {
None => return Err(HttpError::InvalidUrl("missing host".into())),
Some(url::Host::Ipv4(ip)) => {
policy
.check(std::net::IpAddr::V4(ip))
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
}
Some(url::Host::Ipv6(ip)) => {
policy
.check(std::net::IpAddr::V6(ip))
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
}
Some(url::Host::Domain(_)) => {}
}
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
if matches!(port, 22 | 25 | 465 | 587) {
return Err(HttpError::BlockedPort(port));
}
Ok(())
}
/// Build the request header map: merge caller headers, then apply the
/// default `User-Agent` (unless overridden) and the bridge-chosen
/// `Content-Type` (unless overridden).
fn build_headers(req: &HttpRequest, _url: &url::Url) -> Result<HeaderMap, HttpError> {
let mut map = HeaderMap::new();
let mut has_user_agent = false;
let mut has_content_type = false;
for (k, v) in &req.headers {
let name = HeaderName::from_bytes(k.as_bytes())
.map_err(|_| HttpError::Backend(format!("invalid header name: {k}")))?;
let value = HeaderValue::from_str(v)
.map_err(|_| HttpError::Backend(format!("invalid header value for {k}")))?;
if name == USER_AGENT {
has_user_agent = true;
}
if name == CONTENT_TYPE {
has_content_type = true;
}
map.append(name, value);
}
if !has_user_agent {
let script = req.script_id.as_deref().unwrap_or("unknown");
let ua = format!(
"picloud/{} (script:{})",
picloud_shared::PRODUCT_VERSION,
script
);
if let Ok(value) = HeaderValue::from_str(&ua) {
map.insert(USER_AGENT, value);
}
}
if !has_content_type {
if let Some(ref ct) = req.content_type {
if let Ok(value) = HeaderValue::from_str(ct) {
map.insert(CONTENT_TYPE, value);
}
}
}
Ok(map)
}
const fn is_redirect(status: StatusCode) -> bool {
matches!(status.as_u16(), 301..=303 | 307 | 308)
}
/// Map a reqwest error to an `HttpError`, never leaking the resolved
/// IP. SSRF blocks are detected by scanning the error source chain for
/// the resolver's marker prefix.
fn map_reqwest_err(err: reqwest::Error) -> HttpError {
if let Some(reason) = ssrf_reason(&err) {
return HttpError::Ssrf(reason);
}
if err.is_timeout() {
return HttpError::Timeout;
}
if err.is_connect() {
return HttpError::Network("connection failed".into());
}
if err.is_request() {
return HttpError::Network("request failed".into());
}
HttpError::Network("network error".into())
}
/// Walk the error source chain looking for the SSRF marker the resolver
/// embeds. Returns the category reason (no IP) when found.
fn ssrf_reason(err: &reqwest::Error) -> Option<String> {
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = src {
let s = e.to_string();
if let Some(idx) = s.find(SSRF_BLOCK_PREFIX) {
return Some(s[idx + SSRF_BLOCK_PREFIX.len()..].to_string());
}
src = e.source();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::AuthzError;
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::BTreeMap;
use std::io::Write as _;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
struct AllowAuthz;
#[async_trait]
impl AuthzRepo for AllowAuthz {
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
struct DenyAuthz;
#[async_trait]
impl AuthzRepo for DenyAuthz {
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn dev_service(authz: Arc<dyn AuthzRepo>) -> HttpServiceImpl {
// allow_private so the test TcpListener on 127.0.0.1 is reachable.
let mut config = HttpConfig::conservative();
config.allow_private = true;
HttpServiceImpl::new(config, authz)
}
fn anon_cx() -> SdkCallCx {
SdkCallCx {
app_id: AppId::new(),
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx() -> SdkCallCx {
let mut cx = anon_cx();
cx.principal = Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
});
cx
}
fn req(method: &str, url: String) -> HttpRequest {
HttpRequest {
method: method.into(),
url,
headers: BTreeMap::new(),
body: None,
content_type: None,
timeout_ms: 5000,
follow_redirects: true,
max_redirects: 5,
script_id: Some("test-script".into()),
}
}
/// Minimal single-shot HTTP/1.1 server. Reads the request, runs
/// `handler` to produce the raw response bytes, writes them, closes.
/// Returns the bound address.
async fn spawn_server<F>(handler: F) -> SocketAddr
where
F: Fn(String) -> Vec<u8> + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 65536];
let n = sock.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let response = handler(request);
let _ = sock.write_all(&response).await;
let _ = sock.flush().await;
}
});
addr
}
fn ok_response(body: &str, content_type: &str) -> Vec<u8> {
let mut v = Vec::new();
write!(
v,
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
v
}
#[tokio::test]
async fn get_round_trip() {
let addr = spawn_server(|_req| ok_response("hello", "text/plain")).await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body_raw, "hello");
assert_eq!(
resp.headers.get("content-type").map(String::as_str),
Some("text/plain")
);
}
#[tokio::test]
async fn post_sends_body_and_default_user_agent() {
let addr = spawn_server(|request| {
// Echo back whether the body + default UA were present.
let has_ua = request.to_lowercase().contains("user-agent: picloud/");
let has_body = request.contains("xyzzy");
ok_response(&format!("ua={has_ua},body={has_body}"), "text/plain")
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("POST", format!("http://{addr}/"));
r.body = Some(b"xyzzy".to_vec());
r.content_type = Some("text/plain".into());
let resp = svc.request(&anon_cx(), r).await.unwrap();
assert_eq!(resp.body_raw, "ua=true,body=true");
}
#[tokio::test]
async fn custom_user_agent_overrides_default() {
let addr = spawn_server(|request| {
let has_custom = request.to_lowercase().contains("user-agent: my-agent");
let has_default = request.to_lowercase().contains("picloud/");
ok_response(
&format!("custom={has_custom},default={has_default}"),
"text/plain",
)
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.headers.insert("User-Agent".into(), "my-agent".into());
let resp = svc.request(&anon_cx(), r).await.unwrap();
assert_eq!(resp.body_raw, "custom=true,default=false");
}
#[tokio::test]
async fn empty_body_response() {
let addr = spawn_server(|_r| {
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 204);
assert_eq!(resp.body_raw, "");
}
#[tokio::test]
async fn non_2xx_does_not_error() {
let addr = spawn_server(|_r| {
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\nConnection: close\r\n\r\nerr".to_vec()
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 500);
assert_eq!(resp.body_raw, "err");
}
#[tokio::test]
async fn response_over_content_length_cap_rejected() {
let addr = spawn_server(|_r| ok_response("0123456789", "text/plain")).await;
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_response_body_bytes = 5; // body is 10 bytes
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("response")));
}
#[tokio::test]
async fn response_over_cap_without_content_length_rejected_mid_stream() {
// No Content-Length header → must be caught while streaming.
let addr = spawn_server(|_r| {
b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n0123456789ABCDEF".to_vec()
})
.await;
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_response_body_bytes = 4;
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("response")));
}
#[tokio::test]
async fn request_body_over_cap_rejected_before_send() {
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_request_body_bytes = 3;
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let mut r = req("POST", "http://127.0.0.1:1/".into());
r.body = Some(b"too long".to_vec());
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("request")));
}
#[tokio::test]
async fn redirect_followed_up_to_then_throws_beyond_max() {
// Server always 302s to itself → unbounded redirect loop,
// bounded by max_redirects.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
let body = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{addr}/next\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = sock.write_all(body.as_bytes()).await;
}
});
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.max_redirects = 2;
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(
matches!(err, HttpError::Backend(ref m) if m.contains("too many redirects")),
"expected too-many-redirects, got {err:?}"
);
}
#[tokio::test]
async fn scheme_rejected() {
let svc = dev_service(Arc::new(AllowAuthz));
for url in ["file:///etc/passwd", "ftp://host/x", "gopher://host/"] {
let err = svc
.request(&anon_cx(), req("GET", url.into()))
.await
.unwrap_err();
match err {
HttpError::BlockedScheme(s) => {
assert!(url.starts_with(&s), "scheme {s} not in url {url}");
}
other => panic!("expected BlockedScheme for {url}, got {other:?}"),
}
}
}
#[tokio::test]
async fn ports_rejected() {
let svc = dev_service(Arc::new(AllowAuthz));
for port in [22u16, 25, 465, 587] {
let err = svc
.request(
&anon_cx(),
req("GET", format!("http://example.com:{port}/")),
)
.await
.unwrap_err();
assert!(
matches!(err, HttpError::BlockedPort(p) if p == port),
"port {port} should be blocked, got {err:?}"
);
}
}
#[tokio::test]
async fn ssrf_blocks_loopback_without_allow_private() {
// Default config (deny-list ON). A request to a loopback host
// must surface as Ssrf, not a generic network error.
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", "http://127.0.0.1:9/".into()))
.await
.unwrap_err();
match err {
HttpError::Ssrf(reason) => {
assert_eq!(reason, "loopback");
assert!(!reason.contains("127.0.0.1"), "reason must not leak the IP");
}
other => panic!("expected Ssrf, got {other:?}"),
}
}
#[tokio::test]
async fn ssrf_blocks_hostname_resolving_to_loopback() {
// `localhost` resolves to 127.0.0.1 / ::1 — all denied. This
// exercises the DNS-resolver path (vs the literal-IP path) and
// must surface as Ssrf, not a generic DNS error.
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", "http://localhost:9/".into()))
.await
.unwrap_err();
assert!(
matches!(err, HttpError::Ssrf(_)),
"expected Ssrf for localhost, got {err:?}"
);
}
#[tokio::test]
async fn timeout_throws() {
// Server that accepts then never responds.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
// Hold the socket open without replying.
tokio::time::sleep(Duration::from_secs(30)).await;
drop(sock);
}
});
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.timeout_ms = 300;
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(matches!(err, HttpError::Timeout), "got {err:?}");
}
#[tokio::test]
async fn anon_skips_authz_member_without_scope_forbidden() {
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
// Anonymous principal → authz skipped even with DenyAuthz.
let svc = dev_service(Arc::new(DenyAuthz));
let ok = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await;
assert!(ok.is_ok());
// Authenticated member with no role → Forbidden.
let err = svc
.request(&member_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::Forbidden));
}
#[tokio::test]
async fn member_with_role_allowed() {
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&member_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 200);
}
}