Skip to main content

max / makenotwork

Enforce >=32-char floor on service secrets; sanitize spool suffix INTERNAL_SHARED_SECRET and CLI_SERVICE_TOKEN are bearer-token-equivalent but loaded with no length check, unlike SIGNING_SECRET / SYNCKIT_JWT_SECRET which already fail closed below 32 chars. Mirror that floor (new WeakInternalSecret / WeakCliServiceToken config errors) so a weak, brute-forceable secret can't boot. The scan spool tempfile suffix was a raw s3_key with only '/' replaced. The UUID already guarantees uniqueness, so the suffix is cosmetic; sanitize it to [A-Za-z0-9._-] and cap length so a non-server-generated key can never carry path separators, control chars, or exceed NAME_MAX into the tempfile name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 05:01 UTC
Commit: fed7af7e7e5e0402f85931e6515bbaee758b77d5
Parent: 4ab32de
2 files changed, +36 insertions, -5 deletions
@@ -342,11 +342,29 @@ impl Config {
342 342 // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset
343 343 let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok();
344 344
345 - // Internal shared secret for MT communication
346 - let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok();
345 + // Internal shared secret for MT communication. Bearer-token-equivalent, so
346 + // enforce the same >=32-char floor as the signing secrets — a short value
347 + // is offline-brute-forceable. Fail closed rather than boot on a weak secret.
348 + let internal_shared_secret = match std::env::var("INTERNAL_SHARED_SECRET") {
349 + Ok(secret) => {
350 + if secret.len() < 32 {
351 + return Err(ConfigError::WeakInternalSecret);
352 + }
353 + Some(secret)
354 + }
355 + Err(_) => None,
356 + };
347 357
348 - // CLI service token for SSH server → internal API authentication
349 - let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok();
358 + // CLI service token for SSH server → internal API authentication. Same floor.
359 + let cli_service_token = match std::env::var("CLI_SERVICE_TOKEN") {
360 + Ok(secret) => {
361 + if secret.len() < 32 {
362 + return Err(ConfigError::WeakCliServiceToken);
363 + }
364 + Some(secret)
365 + }
366 + Err(_) => None,
367 + };
350 368
351 369 // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890")
352 370 let wam_url = std::env::var("WAM_URL").ok();
@@ -650,6 +668,10 @@ pub enum ConfigError {
650 668 WeakSigningSecret,
651 669 #[error("SYNCKIT_JWT_SECRET must be at least 32 characters long")]
652 670 WeakSynckitJwtSecret,
671 + #[error("INTERNAL_SHARED_SECRET must be at least 32 characters long")]
672 + WeakInternalSecret,
673 + #[error("CLI_SERVICE_TOKEN must be at least 32 characters long")]
674 + WeakCliServiceToken,
653 675 }
654 676
655 677 #[cfg(test)]
@@ -123,7 +123,16 @@ pub async fn download_into_tempfile(
123 123 // fail `AlreadyExists` — failing a legitimately-running job — and let one
124 124 // scan's `SpoolHandle::drop` unlink the other's live file. The s3_key suffix
125 125 // is retained only for human-readable debugging.
126 - let suffix = s3_key.replace('/', "_");
126 + //
127 + // Sanitize it to a bounded, safe charset: the UUID already guarantees
128 + // uniqueness, so the suffix is cosmetic, but a raw s3_key could carry path
129 + // separators, control chars, or exceed NAME_MAX. Map anything outside
130 + // [A-Za-z0-9._-] to `_` (preserving the old `/`→`_` readability) and cap length.
131 + let suffix: String = s3_key
132 + .chars()
133 + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
134 + .take(64)
135 + .collect();
127 136 let path = spool_dir.join(format!("scan-{}-{}-{}.tmp", std::process::id(), unique, suffix));
128 137
129 138 // 0o600: the spooled file holds the (possibly paid/private) upload bytes for