Skip to content
Open
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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!
Expand Down
6 changes: 4 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ <h4 class="text-lg font-semibold mb-3">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</code>
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)</code>
</div>
</div>

Expand Down Expand Up @@ -318,7 +319,8 @@ <h3 class="text-xl font-semibold mb-4">Environment Variables</h3>
<code class="text-sm text-gray-800 block whitespace-pre"># 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)
Expand Down
20 changes: 20 additions & 0 deletions src/domain/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

#[arg(long, env = "DEBUG", help = "Enable debug logging")]
pub debug: bool,
}
Expand All @@ -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"));
}
Expand Down
23 changes: 19 additions & 4 deletions src/server/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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)
}