diff --git a/README.md b/README.md index a2249f8..62b8300 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ export S3_ENDPOINT_URL="your-s3-endpoint-url" # For S3-compatible services lik export S3_TIMEOUT="30" # S3 operation timeout in seconds (default: 30) export PORT="3000" # Server port (default: 3000) export BIND_ADDRESS="0.0.0.0" # IP to bind to (default: 0.0.0.0). Use "::" for IPv6/dual-stack +export READ_ONLY_ACCESS_TOKEN="your-ro-token" # Read-only token for untrusted CI jobs (see "Protecting against cache poisoning") ``` ##### Option B: Command Line Arguments @@ -116,7 +117,8 @@ To configure your Nx workspace to use this cache server, set the following envir # Point Nx to your cache server export NX_SELF_HOSTED_REMOTE_CACHE_SERVER="http://localhost:3000" -# Authentication token (must match SERVICE_ACCESS_TOKEN from server config) +# Authentication token (must match SERVICE_ACCESS_TOKEN from server config, +# or READ_ONLY_ACCESS_TOKEN for jobs that should not write to the cache) export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN="your-bearer-token" # Optional: Disable TLS certificate validation (e.g. for development/testing environment) @@ -127,6 +129,19 @@ Once configured, Nx will automatically use your cache server for storing and ret For more details, see the [Nx documentation](https://nx.dev/recipes/running-tasks/self-hosted-caching#usage-notes). +### Protecting against cache poisoning (CVE-2025-36852 / CREEP) + +If untrusted contributors can run CI with cache **write** access (typically pull request builds), they can pre-seed the cache entry for a hash that a trusted branch will later compute — and the trusted build will replay the poisoned artifact ([CVE-2025-36852, "CREEP"](https://nx.dev/blog/cve-2025-36852-critical-cache-poisoning-vulnerability-creep)). Write-once semantics don't prevent this: the attack writes *first*, it never overwrites. + +The mitigation is to keep untrusted jobs read-only. Configure a second token on the server: + +```bash +export SERVICE_ACCESS_TOKEN="your-rw-token" # trusted builds (main/release): read-write +export READ_ONLY_ACCESS_TOKEN="your-ro-token" # untrusted builds (PRs): read-only +``` + +Then set `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN` to the read-only token in PR pipelines and to the read-write token only in trusted-branch pipelines. A read-only token can retrieve artifacts as usual but gets `403 Forbidden` on writes, so untrusted jobs still benefit from cache hits without being able to poison the cache. + --- ### Stay Updated. Watch this repository to get notified about new releases! diff --git a/docs/index.html b/docs/index.html index bd553ae..2e612cd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -236,7 +236,8 @@

Option A: Environment Variables (Recommen export S3_ENDPOINT_URL="your-s3-endpoint-url" # For S3-compatible services like MinIO export S3_TIMEOUT="30" # S3 operation timeout in seconds (default: 30) export PORT="3000" # Server port (default: 3000) -export BIND_ADDRESS="0.0.0.0" # IP to bind to (default: 0.0.0.0). Use "::" for IPv6/dual-stack +export BIND_ADDRESS="0.0.0.0" # IP to bind to (default: 0.0.0.0). Use "::" for IPv6/dual-stack +export READ_ONLY_ACCESS_TOKEN="your-ro-token" # Read-only token for untrusted CI jobs (e.g. PR builds) — protects against cache poisoning (CVE-2025-36852 / CREEP) @@ -318,7 +319,8 @@

Environment Variables

# Point Nx to your cache server export NX_SELF_HOSTED_REMOTE_CACHE_SERVER="http://localhost:3000" -# Authentication token (must match SERVICE_ACCESS_TOKEN from server config) +# Authentication token (must match SERVICE_ACCESS_TOKEN from server config, +# or READ_ONLY_ACCESS_TOKEN for jobs that should not write to the cache) export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN="your-bearer-token" # Optional: Disable TLS certificate validation (e.g. for development/testing environment) diff --git a/src/domain/config.rs b/src/domain/config.rs index 243a122..f5dcdd9 100644 --- a/src/domain/config.rs +++ b/src/domain/config.rs @@ -130,6 +130,13 @@ pub struct ServerConfig { )] pub service_access_token: String, + #[arg( + long, + env = "READ_ONLY_ACCESS_TOKEN", + help = "Optional bearer token granting read-only access. Give this one to untrusted CI jobs (e.g. PR builds) so they can use the cache but not write to it (CVE-2025-36852 / CREEP)" + )] + pub read_only_access_token: Option, + #[arg(long, env = "DEBUG", help = "Enable debug logging")] pub debug: bool, } @@ -140,6 +147,19 @@ impl ConfigValidator for ServerConfig { return Err(ConfigError::MissingField("SERVICE_ACCESS_TOKEN")); } + if let Some(read_only_token) = &self.read_only_access_token { + if read_only_token.is_empty() { + return Err(ConfigError::Invalid( + "READ_ONLY_ACCESS_TOKEN must not be empty when provided", + )); + } + if read_only_token == &self.service_access_token { + return Err(ConfigError::Invalid( + "READ_ONLY_ACCESS_TOKEN must differ from SERVICE_ACCESS_TOKEN, otherwise it would grant write access", + )); + } + } + if self.port == 0 { return Err(ConfigError::Invalid("port must be greater than 0")); } diff --git a/src/server/middleware.rs b/src/server/middleware.rs index 774f8b8..e7926d5 100644 --- a/src/server/middleware.rs +++ b/src/server/middleware.rs @@ -2,7 +2,7 @@ use crate::domain::storage::StorageProvider; use crate::server::AppState; use axum::{ extract::{Request, State}, - http::StatusCode, + http::{Method, StatusCode}, middleware::Next, response::Response, }; @@ -28,14 +28,29 @@ where None => return Err(StatusCode::UNAUTHORIZED), }; - // Constant-time comparison for security - if !bool::from( + // Constant-time comparisons for security. Both tokens are always + // compared so timing does not reveal which one matched. + let is_read_write = bool::from( token .as_bytes() .ct_eq(state.config.service_access_token.as_bytes()), - ) { + ); + let is_read_only = state + .config + .read_only_access_token + .as_deref() + .is_some_and(|read_only| bool::from(token.as_bytes().ct_eq(read_only.as_bytes()))); + + if !is_read_write && !is_read_only { return Err(StatusCode::UNAUTHORIZED); } + // The read-only token may only read; writes require the service access + // token. This lets untrusted CI jobs (e.g. PR builds) use the cache + // without being able to poison it (CVE-2025-36852 / CREEP). + if !is_read_write && request.method() != Method::GET { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(request).await) }