Skip to main content

max / makenotwork

sando: reject non-loopback scratch_db_url at config load migration_dry_run DROPs and recreates the scratch schema, so a scratch_db_url that reaches off-box (a typo, or a config copied from staging) would wipe the wrong database. Validate the host at load: IP literals classified without DNS, hostnames resolved and required to be all-loopback, unix-socket/no-authority URLs treated as local. Loopback is absolute (no escape hatch); resolution failure fails closed. Fails startup and --check-config with a clear message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:54 UTC
Commit: 84c3d297e35f2fe2a9b9aaf34525bb8cafd29796
Parent: be98109
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.