max / makenotwork
1 file changed,
+128 insertions,
-1 deletion
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | use anyhow::{Context, Result}; | |
| 2 | 2 | use serde::Deserialize; | |
| 3 | + | use std::net::{IpAddr, ToSocketAddrs}; | |
| 3 | 4 | use std::path::PathBuf; | |
| 4 | 5 | ||
| 5 | 6 | #[derive(Debug, Clone, Deserialize)] | |
| @@ -19,7 +20,10 @@ pub struct Config { | |||
| 19 | 20 | pub release_root: PathBuf, | |
| 20 | 21 | /// Scratch postgres DB url used by `migration_dry_run`. Sando drops and | |
| 21 | 22 | /// recreates the schema on every run, so do not point this at anything | |
| 22 | - | /// you care about. | |
| 23 | + | /// you care about. Validated at load to address loopback only (127.0.0.1, | |
| 24 | + | /// `::1`, `localhost`, or a local unix socket): a gate that DROPs a database | |
| 25 | + | /// must never reach off-box, and a non-loopback host refuses startup (see | |
| 26 | + | /// `validate`). | |
| 23 | 27 | #[serde(default)] | |
| 24 | 28 | pub scratch_db_url: Option<String>, | |
| 25 | 29 | /// Role that owns the restored objects in a prod dump. `pg_dump` emits | |
| @@ -159,6 +163,75 @@ pub struct ReleaseEntry { | |||
| 159 | 163 | pub required: bool, | |
| 160 | 164 | } | |
| 161 | 165 | ||
| 166 | + | /// The host component of a `postgres://` URL, or `None` when the URL addresses a | |
| 167 | + | /// local unix socket (no authority). Hand-parsed rather than pulling in a URL | |
| 168 | + | /// crate, matching the daemon's existing PG-URL handling in `gates.rs`. | |
| 169 | + | fn scratch_db_host(url: &str) -> Option<String> { | |
| 170 | + | let after = url.find("://").map(|i| i + 3)?; | |
| 171 | + | let authority_end = url[after..] | |
| 172 | + | .find(['/', '?', '#']) | |
| 173 | + | .map(|i| after + i) | |
| 174 | + | .unwrap_or(url.len()); | |
| 175 | + | let authority = &url[after..authority_end]; | |
| 176 | + | // Drop userinfo: keep everything after the last '@' (`user:pass@host` → `host`). | |
| 177 | + | let host_port = authority.rsplit('@').next().unwrap_or(authority); | |
| 178 | + | // Split the host from an optional port. Bracketed IPv6 (`[::1]:5432`) first; | |
| 179 | + | // otherwise a hostname or IPv4, neither of which contains ':'. | |
| 180 | + | let host = if let Some(rest) = host_port.strip_prefix('[') { | |
| 181 | + | rest.split(']').next().unwrap_or("") | |
| 182 | + | } else { | |
| 183 | + | host_port.split(':').next().unwrap_or("") | |
| 184 | + | }; | |
| 185 | + | if host.is_empty() { | |
| 186 | + | None | |
| 187 | + | } else { | |
| 188 | + | Some(host.to_string()) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Refuse a `scratch_db_url` that could reach a database off this box. | |
| 193 | + | /// `migration_dry_run` DROPs and recreates the scratch schema, so pointing it at | |
| 194 | + | /// a remote (a typo, or a config copied from staging) would wipe the wrong | |
| 195 | + | /// database. Loopback is absolute here — there is deliberately no | |
| 196 | + | /// `allow_remote_scratch_db` escape hatch for a database the daemon destroys. | |
| 197 | + | fn assert_scratch_db_loopback(url: &str) -> Result<()> { | |
| 198 | + | let Some(host) = scratch_db_host(url) else { | |
| 199 | + | return Ok(()); // no authority → local unix socket | |
| 200 | + | }; | |
| 201 | + | // A percent-encoded unix socket path (`postgres://%2Fvar%2Frun%2Fpg/db`) is | |
| 202 | + | // local. A decoded path would start with '/'; `%2f` is its encoded form. | |
| 203 | + | if host.starts_with('/') || host.to_ascii_lowercase().starts_with("%2f") { | |
| 204 | + | return Ok(()); | |
| 205 | + | } | |
| 206 | + | // An IP literal is classified without touching DNS. | |
| 207 | + | if let Ok(ip) = host.parse::<IpAddr>() { | |
| 208 | + | anyhow::ensure!( | |
| 209 | + | ip.is_loopback(), | |
| 210 | + | "scratch_db_url host {host} is not loopback ({ip}); migration_dry_run DROPs and \ | |
| 211 | + | recreates this database, so it must never point off-box (use 127.0.0.1, ::1, \ | |
| 212 | + | localhost, or a local unix socket)", | |
| 213 | + | ); | |
| 214 | + | return Ok(()); | |
| 215 | + | } | |
| 216 | + | // A hostname: resolve and require every resolved address to be loopback. | |
| 217 | + | // Resolution failure fails closed — a name we cannot prove is local is not a | |
| 218 | + | // name we let a schema-dropping gate connect to. | |
| 219 | + | let addrs: Vec<_> = (host.as_str(), 0u16) | |
| 220 | + | .to_socket_addrs() | |
| 221 | + | .with_context(|| format!("resolving scratch_db_url host {host} to confirm it is loopback"))? | |
| 222 | + | .collect(); | |
| 223 | + | anyhow::ensure!( | |
| 224 | + | !addrs.is_empty(), | |
| 225 | + | "scratch_db_url host {host} resolved to no addresses; cannot confirm it is loopback", | |
| 226 | + | ); | |
| 227 | + | anyhow::ensure!( | |
| 228 | + | addrs.iter().all(|a| a.ip().is_loopback()), | |
| 229 | + | "scratch_db_url host {host} resolves off-box (not loopback); migration_dry_run DROPs and \ | |
| 230 | + | recreates this database, so it must never point off-box", | |
| 231 | + | ); | |
| 232 | + | Ok(()) | |
| 233 | + | } | |
| 234 | + | ||
| 162 | 235 | fn default_bin_names() -> Vec<String> { | |
| 163 | 236 | vec!["server".into()] | |
| 164 | 237 | } | |
| @@ -222,6 +295,10 @@ impl Config { | |||
| 222 | 295 | t.dir, | |
| 223 | 296 | ); | |
| 224 | 297 | } | |
| 298 | + | if let Some(url) = self.scratch_db_url.as_deref() { | |
| 299 | + | assert_scratch_db_loopback(url) | |
| 300 | + | .context("scratch_db_url must address a loopback (on-box) database")?; | |
| 301 | + | } | |
| 225 | 302 | Ok(()) | |
| 226 | 303 | } | |
| 227 | 304 | ||
| @@ -430,6 +507,56 @@ mod tests { | |||
| 430 | 507 | } | |
| 431 | 508 | ||
| 432 | 509 | #[test] | |
| 510 | + | fn scratch_db_url_accepts_loopback_and_socket_forms() { | |
| 511 | + | // Every shape a legitimate on-box scratch DB takes. IP literals and the | |
| 512 | + | // socket forms are classified without DNS; `localhost` resolves locally. | |
| 513 | + | for ok in [ | |
| 514 | + | "postgres://sando@127.0.0.1/sando_scratch", // the shipped form | |
| 515 | + | "postgres://sando:s3cret@127.0.0.1:5432/scratch", | |
| 516 | + | "postgres://sando@[::1]:5432/scratch", | |
| 517 | + | "postgres:///scratch", // unix socket, no host | |
| 518 | + | "postgres://sando@%2Fvar%2Frun%2Fpostgresql/scratch", // encoded socket dir | |
| 519 | + | "postgres://localhost/scratch", | |
| 520 | + | ] { | |
| 521 | + | assert!( | |
| 522 | + | assert_scratch_db_loopback(ok).is_ok(), | |
| 523 | + | "should accept on-box url {ok}" | |
| 524 | + | ); | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | #[test] | |
| 529 | + | fn scratch_db_url_rejects_off_box_hosts() { | |
| 530 | + | // Non-loopback IP literals reject without any DNS lookup. | |
| 531 | + | for bad in [ | |
| 532 | + | "postgres://sando@10.1.2.3/scratch", | |
| 533 | + | "postgres://sando:pw@192.168.1.5:5432/scratch", | |
| 534 | + | "postgres://sando@[2001:db8::1]/scratch", | |
| 535 | + | ] { | |
| 536 | + | let err = assert_scratch_db_loopback(bad).unwrap_err().to_string(); | |
| 537 | + | assert!( | |
| 538 | + | err.contains("loopback") || err.contains("off-box"), | |
| 539 | + | "got: {err}" | |
| 540 | + | ); | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | #[test] | |
| 545 | + | fn validate_rejects_a_non_loopback_scratch_db_url() { | |
| 546 | + | // A hostname that cannot be proven loopback fails closed at load. The | |
| 547 | + | // `.example` TLD (RFC 6761) never resolves, so this exercises the | |
| 548 | + | // resolution-failure path without depending on external DNS shape. | |
| 549 | + | let raw = format!( | |
| 550 | + | "{MINIMAL}\nscratch_db_url = \"postgres://sando@db.internal.example:5432/scratch\"\n" | |
| 551 | + | ); | |
| 552 | + | let cfg: Config = toml::from_str(&raw).unwrap(); | |
| 553 | + | assert!( | |
| 554 | + | cfg.validate().is_err(), | |
| 555 | + | "a non-loopback scratch_db_url must fail startup" | |
| 556 | + | ); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | #[test] | |
| 433 | 560 | fn build_host_is_required() { | |
| 434 | 561 | // No safe default: a config without build_host must not parse, so the | |
| 435 | 562 | // no-build-on-prod guard can never be silently skipped. |