fix(config): exempt loopback from the PAYMENT_PROVIDER_URL https-in-prod guard

A payment processor reached over a loopback hop (an SSH tunnel or a local
reverse proxy) is safe to call over plaintext in production — the request never
leaves the host. Relax the https requirement for 127.0.0.0/8, ::1, and
localhost in both the pay-to-register and premium payment validation paths.
This commit is contained in:
jwinterm
2026-07-02 22:04:58 -04:00
parent d96f457b69
commit 394abd5ab7
+29 -4
View File
@@ -38,6 +38,19 @@ fn looks_placeholder(s: &str) -> bool {
PLACEHOLDERS.iter().any(|p| l.contains(p))
}
/// True if the URL's host is loopback (127.0.0.0/8, ::1, or `localhost`).
/// Plaintext to a loopback processor is acceptable even in production: the hop
/// never leaves the host (e.g. a payment processor reached over an SSH tunnel or
/// a local reverse proxy), so the https requirement is relaxed for it.
fn is_loopback_url(s: &str) -> bool {
match url::Url::parse(s).ok().and_then(|u| u.host().map(|h| h.to_owned())) {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Whether `s` is a positive decimal literal — digits with at most one dot and
/// at least one non-zero digit (`"0.01"`, `"1"`, `"10.5"` yes; `"0"`, `"0.00"`,
/// `"-1"`, `"1e5"`, `""` no). Validates a price without pulling in float math.
@@ -1000,8 +1013,10 @@ impl Config {
}
let url = url::Url::parse(&p.provider_url)
.map_err(|_| cfg_err("PAYMENT_PROVIDER_URL must be an absolute URL"))?;
if prod && url.scheme() != "https" {
return Err(cfg_err("PAYMENT_PROVIDER_URL must be https in production"));
if prod && url.scheme() != "https" && !is_loopback_url(&p.provider_url) {
return Err(cfg_err(
"PAYMENT_PROVIDER_URL must be https in production (loopback exempt)",
));
}
if p.store_id.is_empty() {
return Err(cfg_err(
@@ -1118,9 +1133,9 @@ impl Config {
if p.provider_url.trim().is_empty() {
return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_PROVIDER_URL"));
}
if prod && !p.provider_url.starts_with("https://") {
if prod && !p.provider_url.starts_with("https://") && !is_loopback_url(&p.provider_url) {
return Err(cfg_err(
"PAYMENT_PROVIDER_URL must be https:// in production",
"PAYMENT_PROVIDER_URL must be https:// in production (loopback exempt)",
));
}
if p.store_id.trim().is_empty() {
@@ -1681,6 +1696,16 @@ mod tests {
];
}
#[test]
fn loopback_url_detection() {
assert!(is_loopback_url("http://127.0.0.1:8099"));
assert!(is_loopback_url("http://localhost:8000"));
assert!(is_loopback_url("http://[::1]:8099"));
assert!(!is_loopback_url("http://pay.example.org"));
assert!(!is_loopback_url("https://10.0.0.5"));
assert!(!is_loopback_url("not a url"));
}
#[test]
fn premium_off_skips_validation() {
let c = valid(); // premium disabled by default