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 @@
# 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)
}