Skip to main content

max / makenotwork

pom: authenticate WAM ticket POST with a bearer token WAM's server fails closed (Authorization: Bearer required on every request), so pom's tokenless ticket POST was 401ing and silently falling back to email for every failure alert. Add AlertConfig.wam_token (redacted Debug, POM_WAM_TOKEN env override) and send it via bearer_auth on the ticket POST, mirroring the MNW-sink token handling. Warn at startup when wam_url is set but the token is missing. Resolves the Run-18 Security B- WAM-auth finding.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 17:00 UTC
Signed with PGP, not checked
Commit: 577fe7a1128f650c21dbe4f28c50e015b69f1fa0
Parent: 0577251
3 files changed, +59 insertions, -1 deletion
@@ -42,6 +42,11 @@
42 42 pub cooldown_secs: u64,
43 43 /// WAM ticket manager URL (tailnet). When set, alerts also create WAM tickets.
44 44 pub wam_url: Option<String>,
45 + /// Bearer token for the WAM ticket API. WAM fails closed (`MNW/wam` requires
46 + /// `Authorization: Bearer <token>` on every request), so without this a
47 + /// configured `wam_url` still gets 401s and ticket creation silently falls
48 + /// back to email. Can also be set via the `POM_WAM_TOKEN` env var.
49 + pub wam_token: Option<String>,
45 50 /// MNW base URL (e.g. "https://makenot.work"). When set together with
46 51 /// `alerts_ingest_token`, alerts are also pushed to MNW's operator log via
47 52 /// `POST /api/internal/alerts`. Either unset disables the MNW sink.
@@ -62,6 +67,7 @@
62 67 .field("from", &self.from)
63 68 .field("cooldown_secs", &self.cooldown_secs)
64 69 .field("wam_url", &self.wam_url)
70 + .field("wam_token", &self.wam_token.as_ref().map(|_| "***"))
65 71 .field("mnw_url", &self.mnw_url)
66 72 .field(
67 73 "alerts_ingest_token",
@@ -557,6 +563,11 @@
557 563 {
558 564 alerts.alerts_ingest_token = Some(token);
559 565 }
566 + if let Ok(token) = std::env::var("POM_WAM_TOKEN")
567 + && let Some(ref mut alerts) = config.alerts
568 + {
569 + alerts.wam_token = Some(token);
570 + }
560 571 if let Ok(token) = std::env::var("POM_API_TOKEN") {
561 572 config.serve.api_token = Some(token);
562 573 }
@@ -757,6 +768,31 @@
757 768 assert_eq!(alerts.cooldown_secs, 300);
758 769 }
759 770
771 + #[test]
772 + fn config_alerts_wam_token() {
773 + let toml = r#"
774 + [alerts]
775 + to = "alerts@example.com"
776 + wam_url = "http://wam.tailnet:9000"
777 + wam_token = "test-wam-token"
778 + "#;
779 + let config: Config = toml::from_str(toml).unwrap();
780 + let alerts = config.alerts.unwrap();
781 + assert_eq!(alerts.wam_url.as_deref(), Some("http://wam.tailnet:9000"));
782 + assert_eq!(alerts.wam_token.as_deref(), Some("test-wam-token"));
783 + }
784 +
785 + #[test]
786 + fn config_alerts_wam_token_defaults_to_none() {
787 + let toml = r#"
788 + [alerts]
789 + to = "alerts@example.com"
790 + wam_url = "http://wam.tailnet:9000"
791 + "#;
792 + let config: Config = toml::from_str(toml).unwrap();
793 + assert!(config.alerts.unwrap().wam_token.is_none());
794 + }
795 +
760 796 #[test]
761 797 fn config_with_tls() {
762 798 let toml = r#"
@@ -1442,6 +1478,7 @@
1442 1478 from: "PoM".to_string(),
1443 1479 cooldown_secs: 300,
1444 1480 wam_url: None,
1481 + wam_token: Some("super-secret-wam-token".to_string()),
1445 1482 mnw_url: None,
1446 1483 alerts_ingest_token: Some("super-secret-ingest-token".to_string()),
1447 1484 };
@@ -1450,6 +1487,10 @@
1450 1487 !rendered.contains("super-secret-token"),
1451 1488 "token must be redacted in Debug"
1452 1489 );
1490 + assert!(
1491 + !rendered.contains("super-secret-wam-token"),
1492 + "wam token must be redacted in Debug"
1493 + );
1453 1494 assert!(
1454 1495 !rendered.contains("super-secret-ingest-token"),
1455 1496 "ingest token must be redacted in Debug"
@@ -2140,6 +2140,7 @@
2140 2140 from: "PoM Alerts <pom@test.com>".to_string(),
2141 2141 cooldown_secs: 300,
2142 2142 wam_url: None,
2143 + wam_token: None,
2143 2144 mnw_url: None,
2144 2145 alerts_ingest_token: None,
2145 2146 };
@@ -176,6 +176,12 @@
176 176 "alerts: wam_url not configured, failure alerts will be delivered by email \
177 177 instead of WAM tickets (set alerts.wam_url to route them to WAM)"
178 178 );
179 + } else if config.wam_token.is_none() {
180 + warn!(
181 + "alerts: wam_url set but wam_token missing, WAM fails closed so ticket \
182 + creation will 401 and fall back to email (set alerts.wam_token or \
183 + POM_WAM_TOKEN)"
184 + );
179 185 }
180 186 match (&config.mnw_url, &config.alerts_ingest_token) {
181 187 (Some(_), Some(_)) => {
@@ -626,7 +632,16 @@
626 632 payload["source_ref"] = serde_json::json!(r);
627 633 }
628 634
629 - match self.client.post(&url).json(&payload).send().await {
635 + // WAM fails closed: every request must carry `Authorization: Bearer
636 + // <token>` or it 401s (see MNW/wam/src/api.rs). Send the token when
637 + // configured; a missing token 401s and falls back to email below rather
638 + // than silently dropping the down-alert.
639 + let mut req = self.client.post(&url).json(&payload);
640 + if let Some(token) = self.config.wam_token.as_deref() {
641 + req = req.bearer_auth(token);
642 + }
643 +
644 + match req.send().await {
630 645 Ok(resp) if resp.status().is_success() => {
631 646 info!("WAM ticket created: {title}");
632 647 true
@@ -722,6 +737,7 @@
722 737 from: "PoM Alerts <pom-alerts@makenot.work>".to_string(),
723 738 cooldown_secs: 300,
724 739 wam_url: None,
740 + wam_token: None,
725 741 mnw_url: None,
726 742 alerts_ingest_token: None,
727 743 };