Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions crates/cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
use std::net::IpAddr;
use std::sync::Arc;

use anyhow::Result;
use anyhow::{bail, Result};
use relais_server::state::SharedState;
use tokio::net::TcpListener;

use super::{build_exec_router, open_vault};

pub async fn run(port: u16, jwt_secret: String) -> Result<()> {
pub async fn run(host: String, port: u16, jwt_secret: String) -> Result<()> {
// Canonical opt-in only (not any non-empty value).
let allow_weak = matches!(
std::env::var("RELAIS_ALLOW_WEAK_JWT_SECRET").as_deref(),
Ok("1") | Ok("true")
);
validate_jwt_secret(&jwt_secret, allow_weak)?;

let router = build_exec_router()?;

// Open vault if available; don't fail if vault is inaccessible.
Expand All @@ -20,7 +28,21 @@ pub async fn run(port: u16, jwt_secret: String) -> Result<()> {

let app = relais_server::app(state);

let addr = format!("0.0.0.0:{port}");
// Build the bind address; warn on any unspecified (all-interfaces) address and
// bracket IPv6 literals so `::` formats as `[::]:port` not `:::port`.
let addr = match host.parse::<IpAddr>() {
Ok(ip) => {
if ip.is_unspecified() {
tracing::warn!("binding all interfaces ({ip}) — relais is exposed; ensure a strong JWT secret and network controls");
}
if ip.is_ipv6() {
format!("[{ip}]:{port}")
} else {
format!("{ip}:{port}")
}
}
Err(_) => format!("{host}:{port}"), // hostname; bound as-is
};
tracing::info!("listening on {addr}");
println!("Relais server listening on http://{addr}");

Expand All @@ -29,3 +51,55 @@ pub async fn run(port: u16, jwt_secret: String) -> Result<()> {

Ok(())
}

/// Reject well-known / weak JWT secrets so a default launch can't be token-forged.
/// `allow_weak` (from `RELAIS_ALLOW_WEAK_JWT_SECRET`) is a per-area dev escape hatch.
fn validate_jwt_secret(secret: &str, allow_weak: bool) -> Result<()> {
if allow_weak {
tracing::warn!(
"RELAIS_ALLOW_WEAK_JWT_SECRET is set — a weak JWT secret is permitted (DEV ONLY)"
);
return Ok(());
}
if secret == "dev-secret" {
bail!(
"refusing to start with the well-known 'dev-secret' JWT secret. \
Set a strong secret via RELAIS_JWT_SECRET or --jwt-secret (≥32 chars). \
For local dev only: RELAIS_ALLOW_WEAK_JWT_SECRET=1"
);
}
if secret.len() < 32 {
bail!(
"JWT secret is too short ({} chars); use at least 32 chars \
(RELAIS_JWT_SECRET or --jwt-secret). For local dev only: \
RELAIS_ALLOW_WEAK_JWT_SECRET=1",
secret.len()
);
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::validate_jwt_secret;

#[test]
fn rejects_known_dev_secret() {
assert!(validate_jwt_secret("dev-secret", false).is_err());
}

#[test]
fn rejects_short_secret() {
assert!(validate_jwt_secret("tooshort", false).is_err());
}

#[test]
fn accepts_strong_secret() {
assert!(validate_jwt_secret(&"x".repeat(32), false).is_ok());
}

#[test]
fn allow_weak_overrides() {
assert!(validate_jwt_secret("dev-secret", true).is_ok());
}
}
7 changes: 5 additions & 2 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ pub enum Commands {
},
/// Start the HTTP API server
Serve {
/// Host/interface to bind. Defaults to loopback; set 0.0.0.0 to expose.
#[arg(long, env = "RELAIS_HOST", default_value = "127.0.0.1")]
host: String,
/// Port to listen on
#[arg(long, default_value = "3000")]
port: u16,
/// JWT secret
#[arg(long, env = "RELAIS_JWT_SECRET", default_value = "dev-secret")]
/// JWT secret (required; no insecure default). Use ≥32 chars.
#[arg(long, env = "RELAIS_JWT_SECRET")]
jwt_secret: String,
},
/// Manage credential vault
Expand Down
8 changes: 6 additions & 2 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ async fn main() -> anyhow::Result<()> {
Commands::Exec { path, data } => {
relais_cli::commands::exec::run(&path, data.as_deref()).await?;
}
Commands::Serve { port, jwt_secret } => {
relais_cli::commands::serve::run(port, jwt_secret).await?;
Commands::Serve {
host,
port,
jwt_secret,
} => {
relais_cli::commands::serve::run(host, port, jwt_secret).await?;
}
Commands::Vault { action } => relais_cli::commands::vault::run(&action)?,
Commands::Auth { action } => relais_cli::commands::auth::run(action).await?,
Expand Down
36 changes: 29 additions & 7 deletions crates/cli/tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,44 @@ fn cli_parses_exec_command_with_data() {
}

#[test]
fn cli_parses_serve_with_defaults() {
let cli = Cli::parse_from(["relais", "serve"]);
fn cli_serve_requires_jwt_secret() {
// No insecure default: `serve` without a secret must fail to parse.
assert!(Cli::try_parse_from(["relais", "serve"]).is_err());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate serve parser tests from RELAIS_JWT_SECRET

When RELAIS_JWT_SECRET is set in the developer or CI environment, clap satisfies the required jwt_secret from the env var, so this assertion returns Ok even though no CLI flag was provided. Since this change makes RELAIS_JWT_SECRET the documented way to configure serve, the test will be flaky in exactly the environments likely to have that variable; clear/scope the relevant env vars for these parser tests before asserting the no-secret failure.

Useful? React with 👍 / 👎.

}

#[test]
fn cli_parses_serve_with_secret_defaults() {
let secret = "x".repeat(32);
let cli = Cli::parse_from(["relais", "serve", "--jwt-secret", secret.as_str()]);
match cli.command {
Commands::Serve { port, jwt_secret } => {
Commands::Serve {
host,
port,
jwt_secret,
} => {
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 3000);
assert_eq!(jwt_secret, "dev-secret");
assert_eq!(jwt_secret.len(), 32);
}
_ => panic!("expected Serve command"),
}
}

#[test]
fn cli_parses_serve_with_port() {
let cli = Cli::parse_from(["relais", "serve", "--port", "8080"]);
fn cli_parses_serve_with_port_and_host() {
let cli = Cli::parse_from([
"relais",
"serve",
"--host",
"0.0.0.0",
"--port",
"8080",
"--jwt-secret",
"supersecretsupersecretsupersecret",
]);
match cli.command {
Commands::Serve { port, .. } => {
Commands::Serve { host, port, .. } => {
assert_eq!(host, "0.0.0.0");
assert_eq!(port, 8080);
}
_ => panic!("expected Serve command"),
Expand Down
8 changes: 6 additions & 2 deletions crates/server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use axum::{
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};

use crate::state::AppState;
Expand Down Expand Up @@ -34,7 +34,11 @@ pub async fn require_jwt(
_ => return Err(StatusCode::UNAUTHORIZED),
};

let validation = Validation::default();
// Pin the algorithm explicitly to HS256 (don't rely on the library default), so
// alg-confusion / `none` tokens are rejected even if defaults change. `exp` stays
// validated; make the clock leeway explicit.
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 60;
let key = DecodingKey::from_secret(state.jwt_secret.as_bytes());

match decode::<Claims>(token, &key, &validation) {
Expand Down
Loading
Loading