Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/STDLIB_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6733,7 +6733,7 @@ fetch(url_or_options: String | Map, options?: Map) -> Result<Response, String>

Make an HTTP request to a URL.

Accepts one or two arguments: - One argument: a URL string for a simple GET request, or an options map with full control over method, headers, body, authentication, cookies, and timeout. - Two arguments: a URL string and an options map. The URL is merged into the options map automatically. Options map keys: url (set automatically in 2-arg form), method, headers, body, json, form, auth, cookies, timeout. Opaque Secret values are accepted only in header values, cookie values, basic-auth fields, raw bodies, JSON leaves, and form values. Requests containing a Secret do not follow redirects, preventing credentials or 307/308 bodies from crossing origins.
Accepts one or two arguments: - One argument: a URL string for a simple GET request, or an options map with full control over method, headers, body, authentication, cookies, and timeout. - Two arguments: a URL string and an options map. The URL is merged into the options map automatically. Options map keys: url (set automatically in 2-arg form), method, headers, body, json, form, auth, cookies, timeout. Opaque Secret values are accepted only in header values, cookie values, basic-auth fields, raw bodies, JSON leaves, and form values. Secret-bearing requests require HTTPS; APP_ENV=development permits direct HTTP only for localhost and loopback IPs, bypassing system proxies. Requests containing a Secret do not follow redirects, preventing credentials or 307/308 bodies from crossing origins.

**Parameters:**

Expand Down Expand Up @@ -6766,6 +6766,7 @@ fetch("https://api.example.com", map {

- **TypeError**: fetch() requires a URL string or options map — *Fix: Pass a String URL or a Map with request options*
- **TypeError**: fetch() requires 'url' option — *Fix: Include 'url' key in the options map*
- **TypeError**: Secret-bearing HTTP requests require HTTPS — *Fix: Use HTTPS, or set APP_ENV=development for localhost/loopback HTTP*
- **RuntimeError**: Unsupported HTTP method: ... — *Fix: Use GET, POST, PUT, DELETE, PATCH, or HEAD*

**See also:** `download`, `cache_fetch`
Expand Down Expand Up @@ -10936,7 +10937,7 @@ require_secret(name: String) -> Secret

Looks up a required secret and fails closed when it is not configured.

Use this at startup or immediately before an approved secret-consuming sink. In v0.5.1, `std/http.fetch` accepts Secret values as header, cookie, basic-auth, raw body, JSON-leaf, and form values. Templates, public JSON, URL/CSV/string conversion, KV storage, and job payloads reject secrets. There is intentionally no general Secret-to-String reveal function. The error identifies only the logical name and never includes the value.
Use this at startup or immediately before an approved secret-consuming sink. In v0.5.1, `std/http.fetch` accepts Secret values as header, cookie, basic-auth, raw body, JSON-leaf, and form values. These requests require HTTPS; APP_ENV=development permits direct HTTP only for localhost and loopback IPs. Templates, public JSON, URL/CSV/string conversion, KV storage, and job payloads reject secrets. There is intentionally no general Secret-to-String reveal function. The error identifies only the logical name and never includes the value.

**Parameters:**

Expand Down
8 changes: 4 additions & 4 deletions src/std_secrets_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ fn fetch_and_capture(mut options: HashMap<String, Value>) -> (Value, String) {
String::from_utf8(bytes).expect("request is utf-8")
});

let module = crate::stdlib::http::init();
let result = native_fn(&module, "fetch")(&[Value::Map(options)]).expect("fetch executes");
let result = crate::stdlib::http::http_fetch_with_app_env(&options, Some("development"))
.expect("fetch executes");
(result, capture.join().expect("capture thread"))
}

Expand Down Expand Up @@ -368,8 +368,8 @@ fn http_fetch_does_not_follow_redirects_when_request_contains_secrets() {
"headers".to_string(),
Value::Map(HashMap::from([("x-api-key".to_string(), test_secret())])),
);
let module = crate::stdlib::http::init();
native_fn(&module, "fetch")(&[Value::Map(options)]).expect("fetch redirect response");
crate::stdlib::http::http_fetch_with_app_env(&options, Some("development"))
.expect("fetch redirect response");

redirect_server.join().expect("redirect server");
let forwarded = target_request.join().expect("target thread");
Expand Down
155 changes: 148 additions & 7 deletions src/stdlib/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
//! - `download(url, path)` - Download file to disk
//! - `Cache(ttl)` - Create a response cache
//!
//! Requests containing `Secret` values require HTTPS. `APP_ENV=development`
//! permits direct plain HTTP only for `localhost` and loopback IP addresses.
//!
//! # Options for fetch()
//!
//! - `url`: Request URL (required when using options map)
Expand All @@ -27,7 +30,7 @@ use reqwest::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -656,8 +659,69 @@ fn form_scalar(value: &Value) -> Result<String> {
}
}

fn is_loopback_url(url: &reqwest::Url) -> bool {
let Some(host) = url.host_str() else {
return false;
};
let host = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);

host.eq_ignore_ascii_case("localhost")
|| host
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
}

fn validate_secret_transport(
url: &str,
contains_secret: bool,
app_env: Option<&str>,
) -> Result<bool> {
if !contains_secret {
return Ok(false);
}

let parsed = reqwest::Url::parse(url).map_err(|_| {
IntentError::type_error("Secret-bearing HTTP requests require a valid URL".to_string())
})?;
Comment thread
larimonious marked this conversation as resolved.

if parsed.scheme() == "https" {
return Ok(false);
}

let development = app_env.is_some_and(|value| value.eq_ignore_ascii_case("development"));
if development && parsed.scheme() == "http" && is_loopback_url(&parsed) {
return Ok(true);
}

Err(IntentError::type_error(
"Secret-bearing HTTP requests require HTTPS; APP_ENV=development permits HTTP only for localhost or loopback IPs"
.to_string(),
))
}

fn format_ssrf_error(reason: &str, direct_loopback_http: bool) -> String {
if direct_loopback_http {
format!(
"SSRF protection: {reason}. APP_ENV=development permits plaintext loopback transport, but NTNT's SSRF policy remains independent"
)
} else {
format!("SSRF protection: {reason}")
}
}

/// Full HTTP request with all options
fn http_fetch(opts: &HashMap<String, Value>) -> Result<Value> {
let app_env = std::env::var("APP_ENV").ok();
http_fetch_with_app_env(opts, app_env.as_deref())
}

pub(crate) fn http_fetch_with_app_env(
opts: &HashMap<String, Value>,
app_env: Option<&str>,
) -> Result<Value> {
// Cancellation yield point (rule 19): check before making the network request
if crate::stdlib::concurrent::is_current_task_cancelled() {
return Err(IntentError::runtime_error("Task cancelled".to_string()));
Expand All @@ -672,11 +736,14 @@ fn http_fetch(opts: &HashMap<String, Value>) -> Result<Value> {
}
};

let contains_secret = opts.values().any(Value::contains_secret);
let direct_loopback_http = validate_secret_transport(&url, contains_secret, app_env)?;

// SSRF protection: validate URL before making request
if let Err(reason) = validate_url_for_ssrf(&url) {
return Ok(Value::err(Value::String(format!(
"SSRF protection: {}",
reason
return Ok(Value::err(Value::String(format_ssrf_error(
&reason,
direct_loopback_http,
))));
}

Expand All @@ -694,9 +761,27 @@ fn http_fetch(opts: &HashMap<String, Value>) -> Result<Value> {
// Secret-bearing requests never follow redirects: custom credentials and
// 307/308 bodies could otherwise be forwarded to a different origin.
let mut client_builder = reqwest::blocking::Client::builder().cookie_store(true);
if opts.values().any(Value::contains_secret) {
if contains_secret {
client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
}
if direct_loopback_http {
// Plaintext development traffic must remain on loopback even when the process
// has system proxy settings or a nonstandard localhost resolver.
client_builder = client_builder.no_proxy();

let parsed = reqwest::Url::parse(&url).expect("secret transport URL was validated");
if parsed
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("localhost"))
{
// reqwest uses the URL's port; zero is only a DNS-override placeholder.
let loopback = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
client_builder = client_builder.resolve_to_addrs("localhost", &loopback);
Comment thread
larimonious marked this conversation as resolved.
}
}
let client = client_builder
.build()
.map_err(|e| IntentError::runtime_error(format!("Failed to create HTTP client: {}", e)))?;
Expand Down Expand Up @@ -952,8 +1037,10 @@ pub fn init() -> HashMap<String, Value> {
// the options map automatically.
// Options map keys: url (set automatically in 2-arg form), method, headers, body, json, form, auth, cookies, timeout.
// Opaque Secret values are accepted only in header values, cookie values, basic-auth
// fields, raw bodies, JSON leaves, and form values. Requests containing a Secret
// do not follow redirects, preventing credentials or 307/308 bodies from crossing origins.
// fields, raw bodies, JSON leaves, and form values. Secret-bearing requests require
// HTTPS; APP_ENV=development permits direct HTTP only for localhost and loopback IPs,
// bypassing system proxies. Requests containing a Secret do not follow redirects,
// preventing credentials or 307/308 bodies from crossing origins.
// @param url_or_options A URL string for GET, or a Map with request options
// @param options (optional) A Map with request options when first argument is a URL string
// @returns Result<Response, String> where Response is a Map with status, status_text, headers, body, ok, url, redirected, and cookies fields
Expand All @@ -977,6 +1064,7 @@ pub fn init() -> HashMap<String, Value> {
// @expected Ok({status: 201, ...})
// @error TypeError ~ "fetch() requires a URL string or options map" fix: "Pass a String URL or a Map with request options"
// @error TypeError ~ "fetch() requires 'url' option" fix: "Include 'url' key in the options map"
// @error TypeError ~ "Secret-bearing HTTP requests require HTTPS" fix: "Use HTTPS, or set APP_ENV=development for localhost/loopback HTTP"
// @error RuntimeError ~ "Unsupported HTTP method: ..." fix: "Use GET, POST, PUT, DELETE, PATCH, or HEAD"
module.insert(
"fetch".to_string(),
Expand Down Expand Up @@ -1246,6 +1334,59 @@ pub fn init() -> HashMap<String, Value> {
mod tests {
use super::*;

#[test]
fn secret_transport_requires_https_by_default() {
let error = validate_secret_transport("http://127.0.0.1:8080/api", true, None)
.expect_err("secret-bearing HTTP must fail closed without APP_ENV=development");
assert!(error.to_string().contains("HTTPS"));

assert!(validate_secret_transport("https://api.example.com/v1", true, None).is_ok());
assert!(validate_secret_transport("http://api.example.com/v1", false, None).is_ok());
}

#[test]
fn development_allows_secret_http_only_to_loopback_hosts() {
for url in [
"http://localhost:8080/api",
"http://127.0.0.1:8080/api",
"http://127.42.0.9:8080/api",
"http://[::1]:8080/api",
] {
assert!(
validate_secret_transport(url, true, Some("development")).is_ok(),
"development should allow loopback URL: {url}"
);
}

for url in [
"http://example.com/api",
"http://localhost.example.com/api",
"http://localhost./api",
"http://10.0.0.8/api",
"http://192.168.1.10/api",
"http://0.0.0.0:8080/api",
"http://[::]:8080/api",
] {
assert!(
validate_secret_transport(url, true, Some("development")).is_err(),
"development must reject non-loopback URL: {url}"
);
}

assert!(
validate_secret_transport("http://localhost:8080/api", true, Some("production"))
.is_err()
);
assert!(validate_secret_transport("http://localhost:8080/api", true, Some("dev")).is_err());
let invalid = validate_secret_transport("not a URL", true, Some("development"))
.expect_err("malformed secret-bearing URL must fail");
assert!(invalid.to_string().contains("valid URL"));

let ssrf_error = format_ssrf_error("Localhost requests blocked", true);
assert!(ssrf_error.contains("APP_ENV=development"));
assert!(ssrf_error.contains("SSRF policy remains independent"));
}

#[test]
fn cache_fetch_rejects_secret_bearing_options_before_cache_lookup() {
let canary = "cache-secret-canary";
Expand Down
6 changes: 4 additions & 2 deletions src/stdlib/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,10 @@ pub fn init() -> HashMap<String, Value> {
//
// Use this at startup or immediately before an approved secret-consuming sink.
// In v0.5.1, `std/http.fetch` accepts Secret values as header, cookie,
// basic-auth, raw body, JSON-leaf, and form values. Templates, public JSON,
// URL/CSV/string conversion, KV storage, and job payloads reject secrets.
// basic-auth, raw body, JSON-leaf, and form values. These requests require HTTPS;
// APP_ENV=development permits direct HTTP only for localhost and loopback IPs.
// Templates, public JSON, URL/CSV/string conversion, KV storage, and job payloads
// reject secrets.
// There is intentionally no general Secret-to-String reveal function.
// The error identifies only the logical name and never includes the value.
// @param name A validated logical secret name
Expand Down
Loading
Loading