Skip to content

Commit 2288078

Browse files
committed
fix(security): phase 1 hardening — sudo allowlist, token expiry, gateway secret
Addresses three ship-blocking findings from the feat/autopilot security audit: #1 [CRITICAL] Sandbox passwordless sudo narrowed to package managers only - Replace `NOPASSWD: ALL` with a scoped Cmnd_Alias covering apt-get, apt, dpkg, pip, uv, npm, bun. Validate via `visudo -c` at image build. - Claude can still `sudo apt-get install` mid-session (95% case); arbitrary privilege escalation inside the container is blocked. - Operators must rebuild the sandbox image for the change to take effect. #2 [CRITICAL] Deployment tokens now expire - Workspace session token: 6h TTL (was: never). - Autopilot run token: 2h TTL (was: never). - Wildcard permissions retained for now — the memory/workspace handlers gate on admin Permission, not deployment-token permissions, so real scoping requires new permission variants. Tracked for Phase 2. #3 [HIGH] Preview gateway shared secret is now required + auto-managed - `temps serve` generates `\$TEMPS_DATA_DIR/preview_gateway.secret` on first boot (32 random bytes, hex, 0600) and exports it as `PREVIEW_GATEWAY_SHARED_SECRET` so the Pingora proxy injects it on every forwarded preview request. - `preview_gateway::spawn_reconcile` passes the secret into the container env on create. - `temps-preview-gateway` binary now refuses to start without the secret. - Blocks cross-sandbox/cross-tenant direct hits to 127.0.0.1:8090 that would otherwise bypass the preview-password wall.
1 parent 7b2fc18 commit 2288078

File tree

8 files changed

+168
-18
lines changed

8 files changed

+168
-18
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/temps-agents/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ http-body-util = { workspace = true }
3939
tar = "0.4"
4040
pulldown-cmark = "0.12"
4141
libc = { workspace = true }
42+
rand = { workspace = true }
43+
hex = "0.4"

crates/temps-agents/src/preview_gateway.rs

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,81 @@ use tracing::{debug, info, warn};
3939
/// Pinned image reference. Bumped per release. Never `:latest`.
4040
pub const PREVIEW_GATEWAY_IMAGE: &str = "kfsoftware/temps-preview-gateway:dev";
4141

42+
/// Filename inside `TEMPS_DATA_DIR` that holds the gateway shared secret.
43+
/// The file is created with 0600 perms on first boot if missing; the same
44+
/// value is then injected into (a) the gateway container env and (b) the
45+
/// `PREVIEW_GATEWAY_SHARED_SECRET` env of the current process so the
46+
/// host-side Pingora can pick it up and inject the `X-Temps-Preview-Token`
47+
/// header on every forwarded preview request.
48+
const PREVIEW_GATEWAY_SECRET_FILE: &str = "preview_gateway.secret";
49+
50+
/// Ensure a shared-secret file exists under `data_dir`, generating a fresh
51+
/// 32-byte random secret (hex-encoded) if missing. Sets restrictive perms on
52+
/// first write. Returns the secret as a hex string.
53+
///
54+
/// Also exports the secret into `PREVIEW_GATEWAY_SHARED_SECRET` for the
55+
/// current process so in-process subsystems (notably the Pingora proxy's
56+
/// preview route) can read it via `std::env::var` without plumbing state.
57+
pub fn ensure_shared_secret(data_dir: &std::path::Path) -> Result<String> {
58+
let path = data_dir.join(PREVIEW_GATEWAY_SECRET_FILE);
59+
60+
let secret = match std::fs::read_to_string(&path) {
61+
Ok(existing) => {
62+
let trimmed = existing.trim().to_string();
63+
if trimmed.is_empty() {
64+
return Err(anyhow!(
65+
"preview gateway secret file {} is empty",
66+
path.display()
67+
));
68+
}
69+
trimmed
70+
}
71+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
72+
// Generate a fresh 32-byte random secret.
73+
use rand::RngCore;
74+
let mut bytes = [0u8; 32];
75+
rand::thread_rng().fill_bytes(&mut bytes);
76+
let hex = hex::encode(bytes);
77+
78+
// Make sure the parent dir exists.
79+
if !data_dir.exists() {
80+
std::fs::create_dir_all(data_dir)
81+
.with_context(|| format!("failed to create data dir {}", data_dir.display()))?;
82+
}
83+
std::fs::write(&path, &hex)
84+
.with_context(|| format!("failed to write {}", path.display()))?;
85+
#[cfg(unix)]
86+
{
87+
use std::os::unix::fs::PermissionsExt;
88+
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
89+
}
90+
info!(
91+
path = %path.display(),
92+
"generated new preview gateway shared secret"
93+
);
94+
hex
95+
}
96+
Err(e) => {
97+
return Err(anyhow!(
98+
"failed to read preview gateway secret file {}: {}",
99+
path.display(),
100+
e
101+
));
102+
}
103+
};
104+
105+
// Export for the in-process proxy to pick up at request time.
106+
// SAFETY: set_var is marked unsafe on newer Rust due to multi-threaded
107+
// env races. We call this only during single-threaded startup before
108+
// the proxy begins serving, so it is safe in practice.
109+
#[allow(unused_unsafe)]
110+
unsafe {
111+
std::env::set_var("PREVIEW_GATEWAY_SHARED_SECRET", &secret);
112+
}
113+
114+
Ok(secret)
115+
}
116+
42117
/// Container name for the singleton gateway on this host.
43118
pub const PREVIEW_GATEWAY_CONTAINER: &str = "temps-preview-gateway";
44119

@@ -58,6 +133,10 @@ pub struct PreviewGatewaySpec {
58133
pub container_name: String,
59134
pub network: String,
60135
pub host_port: u16,
136+
/// Shared secret the gateway will require on every request via the
137+
/// `X-Temps-Preview-Token` header. When empty, the gateway is started
138+
/// in legacy open mode — callers SHOULD always pass a non-empty value.
139+
pub shared_secret: String,
61140
}
62141

63142
impl Default for PreviewGatewaySpec {
@@ -67,6 +146,7 @@ impl Default for PreviewGatewaySpec {
67146
container_name: PREVIEW_GATEWAY_CONTAINER.to_string(),
68147
network: PREVIEW_GATEWAY_NETWORK.to_string(),
69148
host_port: DEFAULT_PREVIEW_GATEWAY_HOST_PORT,
149+
shared_secret: String::new(),
70150
}
71151
}
72152
}
@@ -91,6 +171,7 @@ impl PreviewGatewaySpec {
91171
container_name: PREVIEW_GATEWAY_CONTAINER.to_string(),
92172
network: PREVIEW_GATEWAY_NETWORK.to_string(),
93173
host_port,
174+
shared_secret: String::new(),
94175
}
95176
}
96177
}
@@ -321,12 +402,21 @@ async fn create_and_start(docker: &Docker, spec: &PreviewGatewaySpec) -> Result<
321402
image: Some(spec.image.clone()),
322403
exposed_ports: Some(exposed_ports),
323404
host_config: Some(host_config),
324-
env: Some(vec![
325-
// Inside the container we MUST bind 0.0.0.0 — the host loopback
326-
// restriction is enforced by the `-p 127.0.0.1:…` publish above.
327-
format!("LISTEN_ADDR=0.0.0.0:{}", GATEWAY_CONTAINER_PORT),
328-
"RUST_LOG=info".to_string(),
329-
]),
405+
env: Some({
406+
let mut e = vec![
407+
// Inside the container we MUST bind 0.0.0.0 — the host loopback
408+
// restriction is enforced by the `-p 127.0.0.1:…` publish above.
409+
format!("LISTEN_ADDR=0.0.0.0:{}", GATEWAY_CONTAINER_PORT),
410+
"RUST_LOG=info".to_string(),
411+
];
412+
if !spec.shared_secret.is_empty() {
413+
e.push(format!(
414+
"PREVIEW_GATEWAY_SHARED_SECRET={}",
415+
spec.shared_secret
416+
));
417+
}
418+
e
419+
}),
330420
..Default::default()
331421
};
332422

@@ -365,10 +455,29 @@ pub fn spawn_reconcile(
365455
rt: &tokio::runtime::Runtime,
366456
docker: Arc<Docker>,
367457
db: Arc<DatabaseConnection>,
458+
data_dir: std::path::PathBuf,
368459
) {
460+
// Ensure the shared secret exists BEFORE spawning the reconciler so that
461+
// the proxy (which reads the env var at request time) sees it even if the
462+
// reconcile task hasn't run yet. A reconcile failure should not leave the
463+
// proxy open — but secret-generation errors MUST not crash the server, so
464+
// we log loudly and continue (the gateway container will then refuse to
465+
// start and preview URLs just stay down).
466+
let shared_secret = match ensure_shared_secret(&data_dir) {
467+
Ok(s) => s,
468+
Err(e) => {
469+
warn!(
470+
"❌ failed to ensure preview gateway shared secret: {} — workspace previews disabled",
471+
e
472+
);
473+
String::new()
474+
}
475+
};
476+
369477
rt.spawn(async move {
370478
let settings = load_settings(&db).await;
371479
let mut spec = PreviewGatewaySpec::from_settings(&settings);
480+
spec.shared_secret = shared_secret;
372481

373482
if !settings.auto_upgrade {
374483
// Honor whatever image is currently running. Falls back to the

crates/temps-agents/src/sandbox/docker.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,12 @@ RUN EXISTING_USER=$(getent passwd 1000 | cut -d: -f1) \
152152
(groupdel "$EXISTING_USER" 2>/dev/null || true); \
153153
fi \
154154
&& useradd -m -s /bin/bash -u 1000 temps \
155-
&& echo "temps ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/temps
155+
&& echo '# temps sandbox: scoped sudo for package install only.' > /etc/sudoers.d/temps \
156+
&& echo 'Cmnd_Alias TEMPS_PKG = /usr/bin/apt, /usr/bin/apt-get, /usr/bin/dpkg, /usr/bin/pip, /usr/bin/pip3, /usr/local/bin/uv, /usr/bin/npm, /usr/local/bin/bun' >> /etc/sudoers.d/temps \
157+
&& echo 'temps ALL=(ALL) NOPASSWD: TEMPS_PKG' >> /etc/sudoers.d/temps \
158+
&& echo 'Defaults:temps !requiretty, !log_input, !log_output' >> /etc/sudoers.d/temps \
159+
&& chmod 0440 /etc/sudoers.d/temps \
160+
&& visudo -c -f /etc/sudoers.d/temps
156161
RUN mkdir -p /workspace && chown temps:temps /workspace
157162
# /run/temps-pty holds one Unix socket per terminal tab (one per {{kind,tab}}
158163
# pair). dtach creates these sockets on first attach; subsequent reconnects

crates/temps-agents/src/services/executor.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,18 @@ impl AgentExecutor {
196196
None => return None,
197197
}
198198
};
199+
// Token lifetime: 2 hours. Autopilot runs have an internal timeout
200+
// well under this, so any token still usable after 2h belongs to a
201+
// run that has already completed or died — we'd rather the token
202+
// expire than linger. See Phase 2 of the security plan for
203+
// fine-grained permission scoping beyond expiry.
204+
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
199205
let request = CreateDeploymentTokenRequest {
200206
name: format!("workflow-run-{}-{}", agent_slug, run_id),
201207
environment_id: None,
202208
deployment_id: None,
203209
permissions: Some(vec!["*".to_string()]),
204-
expires_at: None,
210+
expires_at: Some(expires_at),
205211
};
206212
match svc.create_token(project_id, None, request).await {
207213
Ok(response) => Some(response.token),

crates/temps-cli/src/commands/serve/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,16 @@ impl ServeCommand {
235235
// the gateway container. It MUST NOT block proxy startup — workspace
236236
// previews are a non-critical subsystem.
237237
if let Some(docker) = docker_handle.clone() {
238-
temps_agents::preview_gateway::spawn_reconcile(&rt, docker, db.clone());
238+
let data_dir = self
239+
.data_dir
240+
.clone()
241+
.or_else(|| std::env::var("TEMPS_DATA_DIR").ok().map(PathBuf::from))
242+
.unwrap_or_else(|| {
243+
dirs::home_dir()
244+
.unwrap_or_else(|| PathBuf::from("."))
245+
.join(".temps")
246+
});
247+
temps_agents::preview_gateway::spawn_reconcile(&rt, docker, db.clone(), data_dir);
239248
}
240249

241250
// Start console API server in background (non-blocking).

crates/temps-preview-gateway/src/main.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,22 @@ impl Config {
101101
.and_then(|s| s.parse().ok())
102102
.unwrap_or(DEFAULT_MAX_HEADER_BYTES);
103103

104+
// Require PREVIEW_GATEWAY_SHARED_SECRET. Without it, any local process
105+
// or other container on the shared bridge network can reach the gateway
106+
// and proxy traffic to any sandbox, bypassing the host-side preview
107+
// password wall. We fail fast so misconfigurations are loud.
104108
let shared_secret = std::env::var("PREVIEW_GATEWAY_SHARED_SECRET")
105109
.ok()
106-
.filter(|s| !s.is_empty());
107-
if shared_secret.is_none() {
108-
warn!(
109-
"PREVIEW_GATEWAY_SHARED_SECRET is unset — gateway will accept \
110-
unauthenticated requests. Set it in production so only the \
111-
host-side Pingora can talk to the gateway."
112-
);
113-
}
110+
.filter(|s| !s.is_empty())
111+
.ok_or_else(|| {
112+
anyhow!(
113+
"PREVIEW_GATEWAY_SHARED_SECRET is unset or empty. The gateway refuses \
114+
to start without a shared secret; the host-side Temps control plane \
115+
auto-generates one under TEMPS_DATA_DIR/preview_gateway.secret and \
116+
injects it on container creation."
117+
)
118+
})?;
119+
let shared_secret = Some(shared_secret);
114120

115121
Ok(Self {
116122
listen_addr,

crates/temps-workspace/src/services/message_executor.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1516,12 +1516,23 @@ impl MessageExecutor {
15161516
}
15171517
}
15181518

1519+
// Token lifetime: 6 hours. Long enough for a realistic interactive
1520+
// Claude/agent session, short enough that a leaked env-var token
1521+
// stops being useful within one work session. The stale-token
1522+
// cleanup above (delete_token on name collision) reissues on every
1523+
// new session, so users don't hit expiry mid-session in practice.
1524+
//
1525+
// Permissions: still FullAccess (`*`) because the memory and
1526+
// workspace handlers gate on the admin Permission enum, not on
1527+
// granular deployment-token permissions. Fine-grained scoping is
1528+
// tracked as Phase 2 of the security hardening plan.
1529+
let expires_at = chrono::Utc::now() + chrono::Duration::hours(6);
15191530
let request = CreateDeploymentTokenRequest {
15201531
name: token_name,
15211532
environment_id: None,
15221533
deployment_id: None,
15231534
permissions: Some(vec!["*".to_string()]),
1524-
expires_at: None,
1535+
expires_at: Some(expires_at),
15251536
};
15261537

15271538
let response = self

0 commit comments

Comments
 (0)