Skip to content

Commit 9e5041d

Browse files
committed
Add standards-based response metadata
1 parent 2229b06 commit 9e5041d

29 files changed

Lines changed: 1105 additions & 28 deletions

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@ Fluxheim follows semantic versioning once `1.0.0` is released. Before `1.0.0`,
77
minor versions may still change configuration shape, feature names, and runtime
88
behavior when the change improves security or project direction.
99

10+
## 1.7.12 - Unreleased
11+
12+
### Added
13+
14+
- Add opt-in RFC 9211 `Cache-Status` generated from real cache outcomes and
15+
low-cardinality RFC 9209 `Proxy-Status` for Fluxheim-generated proxy failures.
16+
- Add opt-in RFC 9530 SHA-256 `Content-Digest` and `Repr-Digest` generated from
17+
final response bytes, with live cache, compression, conditional, `HEAD`, and
18+
range-response coverage.
19+
- Add pinned CI-only OpenSSL-FIPS and rustls/AWS-LC-FIPS proof environments
20+
that build and execute the exact profile binary, exercise downstream and
21+
verified upstream TLS, reject incompatible policy, and capture provider,
22+
compiler, dependency, binary, and image evidence.
23+
24+
### Changed
25+
26+
- Make standards-based response metadata inherit through global, vhost, and
27+
route response policies while remaining disabled by default.
28+
- Strip origin digest fields whenever Fluxheim compression changes response
29+
bytes; opt-in digest generation then recomputes fields from the final encoded
30+
content.
31+
- Include the reproducible FIPS-backend image proof in the deep release gate
32+
and expose it as a separate manual workflow and test-starter entry.
33+
34+
### Security
35+
36+
- Bound public response-metadata identifiers to Structured Fields token grammar
37+
and prevent status output from exposing backend addresses, DNS names,
38+
certificate details, cache keys, internal tiers, or arbitrary error text.
39+
- Suppress `Repr-Digest` instead of guessing when Fluxheim does not hold the
40+
complete selected representation, including `HEAD`, `206`, and `304` paths.
41+
- Keep the FIPS proof containers separate from release images and document that
42+
provider evidence is not product-level FIPS validation.
43+
1044
## 1.7.11 - 2026-07-14
1145

1246
### Added

README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Fluxheim is licensed under the European Union Public Licence 1.2.
6363
| Regex path rewrite templates || `1.4.1`; `rewrite_template` maps regex routes to safe upstream paths without nginx-style rewrite loops or `if`. |
6464
| Method-based routing || `1.4.1`; optional route `methods = ["GET", "HEAD"]` filters. |
6565
| HTTPS redirects || Optional global HTTP-to-HTTPS redirects with safe Host validation. |
66-
| Secure headers || Request/response policy, opt-in hardening and cross-origin-isolation profiles, typed CSP reporting and modern browser controls, validated request-aware CORS, automatic `Vary`, and bounded `Retry-After`; origin `Server` headers are preserved by default and removed by the hardening profiles. |
66+
| Secure headers || Request/response policy, opt-in hardening and cross-origin-isolation profiles, typed CSP reporting and modern browser controls, validated request-aware CORS, automatic `Vary`, bounded `Retry-After`, and opt-in RFC 9211/9209/9530 response metadata; origin `Server` headers are preserved by default and removed by the hardening profiles. |
6767
| PHP-FPM applications || External php-fpm for existing pools. |
6868
| Managed PHP-FPM || Fluxheim-supervised php-fpm pools for zero-admin WordPress-style deployments. |
6969

@@ -352,8 +352,8 @@ Release tags use the same profile/OS suffixes on both registries. The first
352352
`1.7.x` image tags include `v1.7.0-wolfi`, `v1.7.0-cache-wolfi`,
353353
`v1.7.0-proxy-wolfi`, `v1.7.0-load-balancer-wolfi`, and `v1.7.0-php-wolfi`;
354354
follow-up `1.7.x` releases use the same suffix pattern, for example
355-
`v1.7.11-wolfi`, `v1.7.11-cache-wolfi`, `v1.7.11-proxy-wolfi`,
356-
`v1.7.11-load-balancer-wolfi`, and `v1.7.11-php-wolfi`.
355+
`v1.7.12-wolfi`, `v1.7.12-cache-wolfi`, `v1.7.12-proxy-wolfi`,
356+
`v1.7.12-load-balancer-wolfi`, and `v1.7.12-php-wolfi`.
357357

358358
Release note for `1.5.15`: the signed git tag `v1.5.15` is the canonical code
359359
tag. The GitHub Release page is published under `v1.5.15-release` because the
@@ -424,13 +424,14 @@ scripts/validate-features.sh proxy,web,tls-rustls,load-balancer
424424

425425
</details>
426426

427-
## Current Release: 1.7.11 Zero-Downtime Upgrades
427+
## Current Release: 1.7.12 Standards Metadata And FIPS Evidence
428428

429429
Fluxheim does not treat every planned idea as stable. The current release line
430-
is `1.7.11`, the zero-downtime process-upgrade release after the stable
431-
`1.7.0` through `1.7.10` Wasm milestones. It adds bounded drain, strict
432-
systemd listener inheritance, readiness-gated native handoff, and a tested
433-
Podman blue/green pattern behind a stable fronting listener.
430+
is `1.7.12`, the standards-based response-metadata and reproducible
431+
FIPS-backend evidence release after the stable Wasm and zero-downtime
432+
milestones. It adds opt-in RFC 9211 `Cache-Status`, low-cardinality RFC 9209
433+
`Proxy-Status`, RFC 9530 response digests, and pinned proof environments that
434+
build and execute the OpenSSL-FIPS and rustls/AWS-LC-FIPS profiles.
434435

435436
- `1.0` is the gateway foundation: vhosts, routes, redirects, static serving,
436437
proxying, SNI/TLS, safe ACME challenge exceptions, systemd/RPM packaging, and
@@ -494,6 +495,13 @@ Podman blue/green pattern behind a stable fronting listener.
494495
closes the native HTTP/1 parser audit with validated-only request heads,
495496
bounded fragmentation-independent chunk decoding, strict request-target and
496497
authority handling, and ownership-safe critical background supervision.
498+
- `1.7.12` adds response metadata generated from real runtime outcomes and
499+
final response bytes. Cache and proxy status fields remain opt-in and avoid
500+
backend topology or arbitrary error strings; digest behavior is live-tested
501+
across cache, compression, conditional, `HEAD`, and range responses. Pinned
502+
CI-only proof environments build and run both FIPS-capable TLS profiles and
503+
capture provider, toolchain, dependency, binary, and image evidence. They are
504+
not published FIPS images or a claim that Fluxheim itself is FIPS validated.
497505

498506
Detailed cache behavior, config examples, operational limits, and smoke-test
499507
coverage are documented in [Cache Backends](docs/cache-backends.md),

crates/fluxheim-config/src/config.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ pub use crate::config_header::{
5151
HeaderPolicyConfig, HeaderValues, PermittedCrossDomainPolicies, RequestHeaderPolicyConfig,
5252
RequestHeaderPolicyOverlayConfig, ResponseHardeningConfig, ResponseHardeningProfile,
5353
ResponseHeaderPolicyConfig, ResponseHeaderPolicyOverlayConfig, ResponseHeaderRewriteConfig,
54-
ResponseHeaderRewriteRuleConfig, ResponseHstsConfig, ResponsePermissionsPolicyConfig,
54+
ResponseHeaderRewriteRuleConfig, ResponseHstsConfig, ResponseMetadataConfig,
55+
ResponseMetadataOverlayConfig, ResponsePermissionsPolicyConfig,
5556
ResponsePermissionsPolicyProfile, VhostHeaderPolicyConfig,
5657
};
5758
#[cfg(test)]

crates/fluxheim-config/src/config_header.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub use crate::config_header_hardening::{
99
PermittedCrossDomainPolicies, ResponseHardeningConfig, ResponseHardeningProfile,
1010
ResponsePermissionsPolicyConfig, ResponsePermissionsPolicyProfile,
1111
};
12+
pub use crate::config_header_metadata::{ResponseMetadataConfig, ResponseMetadataOverlayConfig};
1213
pub use crate::config_header_response::{
1314
ResponseHeaderPolicyConfig, ResponseHeaderPolicyOverlayConfig, ResponseHeaderRewriteConfig,
1415
ResponseHeaderRewriteRuleConfig, ResponseHstsConfig,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
use crate::config::ConfigError;
4+
5+
const MAX_RESPONSE_METADATA_IDENTIFIER_BYTES: usize = 64;
6+
7+
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
8+
#[serde(deny_unknown_fields)]
9+
pub struct ResponseMetadataConfig {
10+
#[serde(default)]
11+
pub identifier: Option<String>,
12+
#[serde(default)]
13+
pub cache_status: bool,
14+
#[serde(default)]
15+
pub proxy_status: bool,
16+
#[serde(default)]
17+
pub content_digest: bool,
18+
#[serde(default)]
19+
pub repr_digest: bool,
20+
}
21+
22+
impl ResponseMetadataConfig {
23+
pub(crate) fn validate(&self, field: &'static str) -> Result<(), ConfigError> {
24+
if (self.cache_status || self.proxy_status)
25+
&& self
26+
.identifier
27+
.as_deref()
28+
.is_none_or(|identifier| !structured_field_token(identifier))
29+
{
30+
return Err(ConfigError::InvalidResponseHeaderValue { field });
31+
}
32+
Ok(())
33+
}
34+
35+
pub fn apply_overlay(&mut self, overlay: &ResponseMetadataOverlayConfig) {
36+
if let Some(identifier) = &overlay.identifier {
37+
self.identifier = identifier.clone();
38+
}
39+
if let Some(cache_status) = overlay.cache_status {
40+
self.cache_status = cache_status;
41+
}
42+
if let Some(proxy_status) = overlay.proxy_status {
43+
self.proxy_status = proxy_status;
44+
}
45+
if let Some(content_digest) = overlay.content_digest {
46+
self.content_digest = content_digest;
47+
}
48+
if let Some(repr_digest) = overlay.repr_digest {
49+
self.repr_digest = repr_digest;
50+
}
51+
}
52+
}
53+
54+
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
55+
#[serde(deny_unknown_fields)]
56+
pub struct ResponseMetadataOverlayConfig {
57+
#[serde(default)]
58+
pub identifier: Option<Option<String>>,
59+
#[serde(default)]
60+
pub cache_status: Option<bool>,
61+
#[serde(default)]
62+
pub proxy_status: Option<bool>,
63+
#[serde(default)]
64+
pub content_digest: Option<bool>,
65+
#[serde(default)]
66+
pub repr_digest: Option<bool>,
67+
}
68+
69+
impl ResponseMetadataOverlayConfig {
70+
pub(crate) fn validate(&self, field: &'static str) -> Result<(), ConfigError> {
71+
if self
72+
.identifier
73+
.as_ref()
74+
.and_then(Option::as_deref)
75+
.is_some_and(|identifier| !structured_field_token(identifier))
76+
{
77+
return Err(ConfigError::InvalidResponseHeaderValue { field });
78+
}
79+
Ok(())
80+
}
81+
}
82+
83+
fn structured_field_token(value: &str) -> bool {
84+
if value.is_empty() || value.len() > MAX_RESPONSE_METADATA_IDENTIFIER_BYTES {
85+
return false;
86+
}
87+
value.bytes().enumerate().all(|(index, byte)| {
88+
if index == 0 {
89+
byte.is_ascii_alphabetic() || byte == b'*'
90+
} else {
91+
byte.is_ascii_alphanumeric()
92+
|| matches!(
93+
byte,
94+
b'!' | b'#'
95+
| b'$'
96+
| b'%'
97+
| b'&'
98+
| b'\''
99+
| b'*'
100+
| b'+'
101+
| b'-'
102+
| b'.'
103+
| b'^'
104+
| b'_'
105+
| b'`'
106+
| b'|'
107+
| b'~'
108+
| b':'
109+
| b'/'
110+
)
111+
}
112+
})
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use super::structured_field_token;
118+
119+
#[test]
120+
fn metadata_identifier_uses_structured_field_token_grammar() {
121+
assert!(structured_field_token("edge-gateway"));
122+
assert!(structured_field_token("edge.example:8443"));
123+
assert!(!structured_field_token(""));
124+
assert!(!structured_field_token("9edge"));
125+
assert!(!structured_field_token("edge gateway"));
126+
assert!(!structured_field_token("edge,other"));
127+
}
128+
}

crates/fluxheim-config/src/config_header_response.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::config_header_hardening::{
99
PermittedCrossDomainPolicies, ResponseHardeningConfig, ResponsePermissionsPolicyConfig,
1010
validate_reporting_endpoints,
1111
};
12+
use crate::config_header_metadata::{ResponseMetadataConfig, ResponseMetadataOverlayConfig};
1213
use crate::config_header_validation::{
1314
combined_header_set, combined_header_unset, merge_header_mutations,
1415
validate_cookie_domain_rewrite_rules, validate_cookie_path_rewrite_rules,
@@ -24,6 +25,8 @@ pub struct ResponseHeaderPolicyConfig {
2425
#[serde(default)]
2526
pub hardening: ResponseHardeningConfig,
2627
#[serde(default)]
28+
pub metadata: ResponseMetadataConfig,
29+
#[serde(default)]
2730
pub strict_transport_security: Option<String>,
2831
#[serde(default)]
2932
pub hsts: Option<ResponseHstsConfig>,
@@ -70,6 +73,7 @@ impl Default for ResponseHeaderPolicyConfig {
7073
Self {
7174
enabled: true,
7275
hardening: ResponseHardeningConfig::default(),
76+
metadata: ResponseMetadataConfig::default(),
7377
strict_transport_security: None,
7478
hsts: None,
7579
content_security_policy: None,
@@ -96,6 +100,7 @@ impl Default for ResponseHeaderPolicyConfig {
96100

97101
impl ResponseHeaderPolicyConfig {
98102
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
103+
self.metadata.validate("headers.response.metadata")?;
99104
validate_optional_header_value(
100105
"headers.response.strict_transport_security",
101106
self.strict_transport_security.as_deref(),
@@ -161,6 +166,7 @@ impl ResponseHeaderPolicyConfig {
161166
if let Some(hardening) = &overlay.hardening {
162167
self.hardening = hardening.clone();
163168
}
169+
self.metadata.apply_overlay(&overlay.metadata);
164170
if let Some(value) = &overlay.strict_transport_security {
165171
self.strict_transport_security = value.clone();
166172
}
@@ -304,6 +310,8 @@ pub struct ResponseHeaderPolicyOverlayConfig {
304310
#[serde(default)]
305311
pub hardening: Option<ResponseHardeningConfig>,
306312
#[serde(default)]
313+
pub metadata: ResponseMetadataOverlayConfig,
314+
#[serde(default)]
307315
pub strict_transport_security: Option<Option<String>>,
308316
#[serde(default)]
309317
pub hsts: Option<Option<ResponseHstsConfig>>,
@@ -347,6 +355,7 @@ pub struct ResponseHeaderPolicyOverlayConfig {
347355

348356
impl ResponseHeaderPolicyOverlayConfig {
349357
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
358+
self.metadata.validate("vhosts.headers.response.metadata")?;
350359
validate_optional_header_value(
351360
"vhosts.headers.response.strict_transport_security",
352361
self.strict_transport_security

crates/fluxheim-config/src/config_tests_headers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#[path = "config_tests_headers_generic.rs"]
22
mod generic;
3+
#[path = "config_tests_headers_metadata.rs"]
4+
mod metadata;
35
#[path = "config_tests_headers_request.rs"]
46
mod request;
57
#[path = "config_tests_headers_response.rs"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use super::super::*;
2+
use crate::{ResponseMetadataConfig, ResponseMetadataOverlayConfig};
3+
4+
#[test]
5+
fn parses_opt_in_response_metadata_policy() {
6+
let config: Config = toml::from_str(
7+
r#"
8+
[headers.response.metadata]
9+
identifier = "edge-gateway"
10+
cache_status = true
11+
proxy_status = true
12+
content_digest = true
13+
repr_digest = true
14+
"#,
15+
)
16+
.unwrap();
17+
18+
let metadata = &config.headers.response.metadata;
19+
assert_eq!(metadata.identifier.as_deref(), Some("edge-gateway"));
20+
assert!(metadata.cache_status);
21+
assert!(metadata.proxy_status);
22+
assert!(metadata.content_digest);
23+
assert!(metadata.repr_digest);
24+
config.validate().unwrap();
25+
}
26+
27+
#[test]
28+
fn rejects_status_metadata_without_deployment_identifier() {
29+
let config: Config = toml::from_str(
30+
r#"
31+
[headers.response.metadata]
32+
proxy_status = true
33+
"#,
34+
)
35+
.unwrap();
36+
37+
assert!(matches!(
38+
config.validate(),
39+
Err(ConfigError::InvalidResponseHeaderValue {
40+
field: "headers.response.metadata"
41+
})
42+
));
43+
}
44+
45+
#[test]
46+
fn response_metadata_overlay_inherits_identifier_and_overrides_fields() {
47+
let mut base = ResponseMetadataConfig {
48+
identifier: Some("edge-gateway".to_owned()),
49+
cache_status: true,
50+
..ResponseMetadataConfig::default()
51+
};
52+
base.apply_overlay(&ResponseMetadataOverlayConfig {
53+
cache_status: Some(false),
54+
content_digest: Some(true),
55+
..ResponseMetadataOverlayConfig::default()
56+
});
57+
58+
assert_eq!(base.identifier.as_deref(), Some("edge-gateway"));
59+
assert!(!base.cache_status);
60+
assert!(base.content_digest);
61+
}
62+
63+
#[test]
64+
fn rejects_non_token_response_metadata_identifier() {
65+
let config: Config = toml::from_str(
66+
r#"
67+
[headers.response.metadata]
68+
identifier = "edge gateway"
69+
cache_status = true
70+
"#,
71+
)
72+
.unwrap();
73+
74+
assert!(matches!(
75+
config.validate(),
76+
Err(ConfigError::InvalidResponseHeaderValue {
77+
field: "headers.response.metadata"
78+
})
79+
));
80+
}

crates/fluxheim-config/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub mod config_geoip;
3131
pub mod config_header;
3232
pub mod config_header_cors;
3333
pub mod config_header_hardening;
34+
pub mod config_header_metadata;
3435
pub mod config_header_response;
3536
pub mod config_header_validation;
3637
pub mod config_http;

0 commit comments

Comments
 (0)