Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f3e34df
feat(jans-cedarling): add resource limits to ArchiveVfs for zip bomb
haileyesus2433 Sep 11, 2026
949fe98
feat(jans-cedarling): Add max_file_size to PolicyStoreConfig
haileyesus2433 Sep 11, 2026
1872018
chore(jans-cedarling): pass ArchiveLimits parameter to policy store
haileyesus2433 Sep 11, 2026
5375475
feat(jans-cedarling): pass archive limits to policy store loaders
haileyesus2433 Sep 11, 2026
0022e65
chore(jans-cedarling): add test for max_file_size in policy store loader
haileyesus2433 Sep 11, 2026
790fc52
feat(jans-cedarling): fallback HTTP max response size to policy store
haileyesus2433 Sep 11, 2026
ad560d7
feat(jans-cedarling): add cedarling policy store max file size config
haileyesus2433 Sep 11, 2026
21d3295
docs: Document CEDARLING_POLICY_STORE_MAX_FILE_SIZE property
haileyesus2433 Sep 11, 2026
4306773
Merge branch 'main' into jans-cedarling-14896
haileyesus2433 Sep 15, 2026
e571956
chore(jans-cedarling): add descriptive assertion messages to bootstrap
haileyesus2433 Sep 15, 2026
025e4af
fix(jans-cedarling): enforce archive total size limits on actual
haileyesus2433 Sep 15, 2026
e1754d3
Merge branch 'main' into jans-cedarling-14896
haileyesus2433 Sep 15, 2026
ca8ac5c
chore(jans-cedarling): Remove HTTP max response size fallback to archive
haileyesus2433 Sep 18, 2026
920ba18
feat(jans-cedarling): give HTTP max response size its own default config
haileyesus2433 Sep 18, 2026
03e290e
docs: Update Cedarling HTTP and policy store property docs
haileyesus2433 Sep 18, 2026
b621cff
chore(jans-cedarling): improve ResponseTooLarge error message
haileyesus2433 Sep 18, 2026
c236e0d
Merge branch 'main' into jans-cedarling-14896
haileyesus2433 Sep 18, 2026
d8b9dd9
docs(jans-cedarling): reference CEDARLING_HTTP_MAX_RESPONSE_SIZE_BYTE…
olehbozhok Sep 18, 2026
8c1c7ab
Merge branch 'main' into jans-cedarling-14896
olehbozhok Sep 18, 2026
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
6 changes: 6 additions & 0 deletions docs/cedarling/reference/cedarling-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ To load the policy store, one of the following properties must be set:

- **`CEDARLING_POLICY_STORE_REFRESH_INTERVAL`** : Background refresh interval in seconds for URL-based policy store sources (`CEDARLING_POLICY_STORE_URI` pointing at a Cedar Archive URL). When set to a non-zero value, Cedarling spawns a worker that periodically re-fetches the policy store and atomically swaps the in-memory `Authz` instance when the upstream changes. A server-side `Cache-Control: max-age` / `Expires` hint may *shorten* the next interval but never extends it. Default is `0` (refresh disabled — load-once-at-startup behavior). Non-zero values below `5` seconds are clamped to `5`. Ignored for local sources (`CEDARLING_POLICY_STORE_LOCAL_FN`, `CEDARLING_POLICY_STORE_LOCAL`). See [Background refresh](./cedarling-policy-store.md#background-refresh) for the per-request consistency model, the strategy ladder, and the emitted metric keys.

### Limiting policy store size

- **`CEDARLING_POLICY_STORE_MAX_FILE_SIZE`** : Maximum decompressed size, in bytes, of a single file inside a Cedar Archive (`.cjar`). Archives are ZIP files, so a small download can expand into a very large buffer in memory (a "zip bomb"); an archive whose entry exceeds this limit is rejected with an error rather than being decompressed. The whole-archive decompressed size is capped at ten times this value, and an archive may hold at most 10000 entries. Set to `0` to disable the size caps. Default is `10485760` (10 MB).

This limit is independent of `CEDARLING_HTTP_MAX_RESPONSE_SIZE_BYTES`, which bounds the compressed archive while it is downloaded. Serving a `.cjar` over HTTP whose compressed size exceeds `CEDARLING_HTTP_MAX_RESPONSE_SIZE_BYTES` (10 MB by default) requires raising that property as well.

### Optional properties

Properties listed here are optional. If a property value is not set,
Expand Down
2 changes: 2 additions & 0 deletions jans-cedarling/cedarling/config/default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ CEDARLING_LOG_TTL: 60
CEDARLING_LOCAL_JWKS: null
CEDARLING_POLICY_STORE_LOCAL: null
CEDARLING_POLICY_STORE_LOCAL_FN: "../config/policy-store.cjar"
# Cap on the decompressed size of a single .cjar entry.
CEDARLING_POLICY_STORE_MAX_FILE_SIZE: 10485760
CEDARLING_JWT_SIG_VALIDATION: "enabled"
CEDARLING_JWT_STATUS_VALIDATION: "enabled"
CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled"
Expand Down
138 changes: 133 additions & 5 deletions jans-cedarling/cedarling/src/bootstrap_config/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,20 @@ fn build_policy_store_config(
Ok(PolicyStoreConfig {
source: PolicyStoreSource::Yaml(policy_store),
refresh_interval_secs: raw.policy_store_refresh_interval_secs,
max_file_size: raw.policy_store_max_file_size,
})
}
},
// Case: get the policy store from a URI
(None, Some(policy_store_uri), None, None) => Ok(PolicyStoreConfig {
source: PolicyStoreSource::Uri(policy_store_uri),
refresh_interval_secs: raw.policy_store_refresh_interval_secs,
max_file_size: raw.policy_store_max_file_size,
}),
// Case: get the policy store from a CjarUrl
(None, None, None, Some(policy_store_cjar_url)) => Ok(PolicyStoreConfig {
source: PolicyStoreSource::CjarUrl(policy_store_cjar_url),
refresh_interval_secs: raw.policy_store_refresh_interval_secs,
max_file_size: raw.policy_store_max_file_size,
}),
// Case: get the policy store from a local file or directory
(None, None, Some(raw_path), None) => {
Expand All @@ -168,7 +171,9 @@ fn build_policy_store_config(
.and_then(|ext| ext.to_str())
.map(str::to_lowercase);
match file_ext.as_deref() {
Some("json") => return Err(BootstrapConfigLoadingError::LegacyJsonNotSupported),
Some("json") => {
return Err(BootstrapConfigLoadingError::LegacyJsonNotSupported);
},
Some("yaml" | "yml") => PolicyStoreSource::FileYaml(path.into()),
Some("cjar") => PolicyStoreSource::CjarFile(path.into()),
_ => {
Expand All @@ -181,6 +186,7 @@ fn build_policy_store_config(
Ok(PolicyStoreConfig {
source,
refresh_interval_secs: raw.policy_store_refresh_interval_secs,
max_file_size: raw.policy_store_max_file_size,
})
},
// Case: multiple policy stores were set
Expand Down Expand Up @@ -247,6 +253,130 @@ fn resolve_log_type(
#[cfg(test)]
mod tests {
use super::*;
use crate::common::policy_store::archive_handler::ArchiveLimits;

/// Minimal config body; a policy store source is required, and the inline
/// YAML source keeps these tests off the filesystem and network. Inline
/// JSON is rejected as a legacy policy store, so the value must be YAML.
fn raw_config_json(extra: &str) -> String {
format!(
r#"{{
"CEDARLING_APPLICATION_NAME": "test",
"CEDARLING_POLICY_STORE_LOCAL": "cedar_version: v4.0.0\npolicy_stores: {{}}"
{extra}
}}"#
)
}

fn decode(extra: &str) -> BootstrapConfig {
let raw: BootstrapConfigRaw = serde_json::from_str(&raw_config_json(extra))
.expect("raw bootstrap config should deserialize");
BootstrapConfig::try_from(raw).expect("raw config should decode")
}

#[test]
fn archive_cap_does_not_move_the_http_cap() {
// The two caps bound different memory (decompressed output vs. buffered
// response body), so tuning the archive cap must leave every other
// HTTP fetch on its own default.
let http_default = Some(HttpClientConfig::DEFAULT_MAX_RESPONSE_SIZE_BYTES);

let neither = decode("");
assert_eq!(
neither.http_client_config.max_response_size_bytes, http_default,
"With neither property set the HTTP cap must use its own default"
);
assert_eq!(
neither.policy_store_config.max_file_size,
ArchiveLimits::DEFAULT_MAX_ENTRY_SIZE,
"With neither property set the archive cap must use its own default"
);

let small = decode(r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": 4096"#);
assert_eq!(
small.http_client_config.max_response_size_bytes, http_default,
"A small archive cap must not shrink unrelated HTTP responses"
);
assert_eq!(
small.policy_store_config.max_file_size, 4096,
"The archive cap must carry the configured value"
);

let disabled = decode(r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": 0"#);
assert_eq!(
disabled.http_client_config.max_response_size_bytes, http_default,
"Disabling the archive cap must not remove the HTTP cap"
);
}

#[test]
fn http_cap_does_not_move_the_archive_cap() {
let explicit = decode(
r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": 4096,
"CEDARLING_HTTP_MAX_RESPONSE_SIZE_BYTES": 8192"#,
);
assert_eq!(
explicit.http_client_config.max_response_size_bytes,
Some(8192),
"An explicit HTTP cap must be used as given"
);
assert_eq!(
explicit.policy_store_config.max_file_size, 4096,
"An explicit HTTP cap must not disturb the archive cap"
);

let disabled = decode(
r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": 4096,
"CEDARLING_HTTP_MAX_RESPONSE_SIZE_BYTES": 0"#,
);
assert_eq!(
disabled.http_client_config.max_response_size_bytes, None,
"An explicit 0 must disable the HTTP cap"
);
assert_eq!(
disabled.policy_store_config.max_file_size, 4096,
"Disabling the HTTP cap must leave the archive cap enforced"
);
}

#[test]
fn max_file_size_is_honored_from_json_and_yaml() {
// Env is covered by the `raw_config` tests; these two are the remaining
// documented input formats.
let from_json = BootstrapConfig::load_from_json(&raw_config_json(
r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": 512"#,
))
.expect("JSON bootstrap config should load");
assert_eq!(
from_json.policy_store_config.max_file_size, 512,
"The policy store cap must be honored from JSON"
);

let yaml = concat!(
"CEDARLING_APPLICATION_NAME: test\n",
"CEDARLING_POLICY_STORE_LOCAL: 'cedar_version: v4.0.0'\n",
"CEDARLING_POLICY_STORE_MAX_FILE_SIZE: 512\n",
);
let raw: BootstrapConfigRaw =
serde_yaml_ng::from_str(yaml).expect("YAML bootstrap config should deserialize");
let from_yaml = BootstrapConfig::try_from(raw).expect("YAML config should decode");
assert_eq!(
from_yaml.policy_store_config.max_file_size, 512,
"The policy store cap must be honored from YAML"
);
}

#[test]
fn max_file_size_is_honored_from_a_string_valued_env_var() {
// Env vars always arrive as strings, so the numeric properties go
// through `deserialize_or_parse_string_as_json`.
let config = decode(r#", "CEDARLING_POLICY_STORE_MAX_FILE_SIZE": "4096""#);

assert_eq!(
config.policy_store_config.max_file_size, 4096,
"A string-valued cap must parse to the same number"
);
}

#[test]
fn test_reject_legacy_json_inline() {
Expand All @@ -261,8 +391,7 @@ mod tests {
local_policy_store: Some(case.to_string()),
..Default::default()
};
let err = build_policy_store_config(&raw)
.expect_err("legacy JSON must be rejected");
let err = build_policy_store_config(&raw).expect_err("legacy JSON must be rejected");
assert!(
matches!(err, BootstrapConfigLoadingError::LegacyJsonNotSupported),
"expected LegacyJsonNotSupported for input: {case}, got {err:?}"
Expand Down Expand Up @@ -294,4 +423,3 @@ mod tests {
}
}
}

16 changes: 16 additions & 0 deletions jans-cedarling/cedarling/src/bootstrap_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ mod tests {

use super::*;

#[test]
fn test_default_config_ships_independent_size_caps() {
let config = BootstrapConfig::load_default().unwrap();

assert_eq!(
config.policy_store_config.max_file_size,
crate::common::policy_store::archive_handler::ArchiveLimits::DEFAULT_MAX_ENTRY_SIZE,
"The shipped default config must carry the bundled archive cap"
);
assert_eq!(
config.http_client_config.max_response_size_bytes,
Some(crate::HttpClientConfig::DEFAULT_MAX_RESPONSE_SIZE_BYTES),
"The shipped default config must use the HTTP cap's own default"
);
}

#[test]
fn test_load_default_config() {
let config = BootstrapConfig::load_default().unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;

use crate::bootstrap_config::BootstrapConfigLoadingError;
use crate::common::policy_store::archive_handler::ArchiveLimits;

/// `PolicyStoreConfig` - Configuration for the policy store.
///
Expand All @@ -23,6 +24,17 @@ pub struct PolicyStoreConfig {
/// interval but never lengthens it.
#[serde(default)]
pub refresh_interval_secs: u64,

/// `CEDARLING_POLICY_STORE_MAX_FILE_SIZE` — cap on the decompressed size of
/// a single `.cjar` entry, in bytes. `0` disables the cap.
#[serde(default = "default_policy_store_max_file_size")]
pub max_file_size: u64,
}

/// Serde default for [`PolicyStoreConfig::max_file_size`], so a config
/// deserialized without the field still gets a bounded archive loader.
pub(crate) fn default_policy_store_max_file_size() -> u64 {
ArchiveLimits::DEFAULT_MAX_ENTRY_SIZE
}

impl PolicyStoreConfig {
Expand Down Expand Up @@ -59,13 +71,22 @@ impl PolicyStoreConfig {
}
}

impl PolicyStoreConfig {
/// Resource limits for `.cjar` loading, derived from [`Self::max_file_size`].
#[must_use]
pub(crate) fn archive_limits(&self) -> ArchiveLimits {
ArchiveLimits::from_max_file_size(self.max_file_size)
}
}

impl Default for PolicyStoreConfig {
fn default() -> Self {
Self {
source: PolicyStoreSource::Yaml(
"cedar_version: v4.0.0\npolicy_stores: {}\n".to_string(),
),
refresh_interval_secs: 0,
max_file_size: default_policy_store_max_file_size(),
}
}
}
Expand Down Expand Up @@ -184,6 +205,7 @@ impl TryFrom<PolicyStoreConfigRaw> for PolicyStoreConfig {
Ok(Self {
source,
refresh_interval_secs: 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why this is always zero?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's zero because PolicyStoreConfigRaw only carries source and path, so there's no refresh interval to read 0 is the documented "refresh disabled" default.

max_file_size: default_policy_store_max_file_size(),
})
}
}
71 changes: 69 additions & 2 deletions jans-cedarling/cedarling/src/bootstrap_config/raw_config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use super::default_values::{
default_enabled_feature_toggle, default_http_client_max_response_size_bytes,
default_http_client_max_retries, default_http_client_retry_delay_secs, default_jti,
default_jwks_refresh_min_interval, default_log_channel_capacity, default_log_max_retries,
default_status_list_refresh_interval_max, default_token_cache_capacity,
default_token_cache_max_ttl, default_true,
default_policy_store_max_file_size, default_status_list_refresh_interval_max,
default_token_cache_capacity, default_token_cache_max_ttl, default_true,
};
#[cfg(not(target_arch = "wasm32"))]
use super::default_values::{
Expand Down Expand Up @@ -505,6 +505,16 @@ pub struct BootstrapConfigRaw {
#[serde(rename = "CEDARLING_POLICY_STORE_REFRESH_INTERVAL", default)]
#[serde(deserialize_with = "deserialize_or_parse_string_as_json")]
pub policy_store_refresh_interval_secs: u64,

/// Maximum decompressed size, in bytes, of a single entry inside a `.cjar`
/// policy store archive. Bounds zip-bomb expansion. `0` disables the cap.
/// Default: 10 MB (`10485760`).
#[serde(
rename = "CEDARLING_POLICY_STORE_MAX_FILE_SIZE",
default = "default_policy_store_max_file_size",
deserialize_with = "deserialize_or_parse_string_as_json"
)]
pub policy_store_max_file_size: u64,
}

impl Default for BootstrapConfigRaw {
Expand Down Expand Up @@ -552,6 +562,7 @@ fn get_cedarling_env_vars() -> HashMap<String, serde_json::Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::common::policy_store::archive_handler::ArchiveLimits;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
use crate::jwt_config::{MIN_JWKS_REFRESH_SECS, MIN_STATUS_LIST_REFRESH_SECS};
use std::{
env,
Expand Down Expand Up @@ -835,6 +846,62 @@ mod tests {
);
}

#[test]
fn test_policy_store_max_file_size_and_http_cap_default_independently() {
with_env_vars(&[], || {
let config = BootstrapConfigRaw::from_raw_config_and_env(None).unwrap();

assert_eq!(
config.policy_store_max_file_size,
ArchiveLimits::DEFAULT_MAX_ENTRY_SIZE,
"Policy store max file size should default to 10 MB"
);
assert_eq!(
config.http_client_max_response_size_bytes,
crate::HttpClientConfig::DEFAULT_MAX_RESPONSE_SIZE_BYTES,
"An unset HTTP cap must use its own default, not the archive cap"
);
});
}

#[test]
fn test_policy_store_max_file_size_from_env_var() {
with_env_vars(&[("CEDARLING_POLICY_STORE_MAX_FILE_SIZE", "2048")], || {
let config = BootstrapConfigRaw::from_raw_config_and_env(None).unwrap();

assert_eq!(
config.policy_store_max_file_size, 2048,
"Policy store max file size should match environment value"
);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn test_policy_store_max_file_size_rejects_invalid_values() {
// `0` is the documented disable sentinel and stays valid; anything that
// isn't a non-negative integer must fail the bootstrap rather than
// silently fall back to the default cap.
with_env_vars(&[("CEDARLING_POLICY_STORE_MAX_FILE_SIZE", "0")], || {
let config = BootstrapConfigRaw::from_raw_config_and_env(None)
.expect("0 is the disable sentinel and must be accepted");
assert_eq!(
config.policy_store_max_file_size, 0,
"0 must be preserved rather than replaced by the default"
);
});

for invalid in ["-1", "not-a-number"] {
with_env_vars(&[("CEDARLING_POLICY_STORE_MAX_FILE_SIZE", invalid)], || {
let err = BootstrapConfigRaw::from_raw_config_and_env(None)
.expect_err("a non-u64 max file size must be rejected");
assert!(
matches!(err, BootstrapConfigLoadingError::DecodingJSON(_)),
"expected a decoding error for input {invalid:?}, got {err:?}"
);
});
}
}

#[test]
fn test_jwks_refresh_interval_from_env_var() {
with_env_vars(
Expand Down
Loading
Loading