Skip to content

Commit d6e2539

Browse files
committed
Harden CORS and reporting endpoint enforcement
1 parent 3130a6b commit d6e2539

8 files changed

Lines changed: 204 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ behavior when the change improves security or project direction.
4242
named `Domain` or `Path` as a `Set-Cookie` attribute.
4343
- Add fuzz coverage for the header parsers and rewrite boundaries changed by
4444
this hardening pass.
45+
- Enforce configured CORS methods on actual responses, not only preflights.
46+
- Serialize Reporting-Endpoints as a bounded RFC 9651 dictionary with strict
47+
lowercase keys, escaped ASCII strings, and HTTPS-only collectors.
4548

4649
## 1.7.9 - 2026-07-12
4750

crates/fluxheim-config/src/config_header_hardening.rs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ use std::collections::BTreeMap;
33
use serde::{Deserialize, Serialize};
44

55
use crate::config::ConfigError;
6-
use crate::config_header_validation::{valid_http_header_name, validate_optional_header_value};
6+
use crate::config_header_validation::validate_optional_header_value;
77
use crate::config_http::valid_http_endpoint_url;
88

99
const MAX_REPORTING_ENDPOINTS: usize = 16;
10+
const MAX_REPORTING_ENDPOINT_NAME_BYTES: usize = 64;
11+
const MAX_REPORTING_ENDPOINTS_HEADER_BYTES: usize = 16_384;
1012

1113
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Deserialize, Serialize)]
1214
#[serde(rename_all = "kebab-case")]
@@ -148,28 +150,88 @@ pub(crate) fn validate_reporting_endpoints(
148150
return Err(ConfigError::InvalidResponseHeaderValue { field });
149151
}
150152
for (name, endpoint) in endpoints {
151-
if !valid_http_header_name(name) || !valid_http_endpoint_url(endpoint) {
153+
if !valid_structured_field_key(name)
154+
|| !endpoint.starts_with("https://")
155+
|| !valid_http_endpoint_url(endpoint)
156+
|| serialize_sf_string(endpoint).is_none()
157+
{
152158
return Err(ConfigError::InvalidHeaderValue {
153159
field,
154160
name: name.clone(),
155161
});
156162
}
157163
validate_optional_header_value(field, Some(endpoint))?;
158164
}
165+
if reporting_endpoints_header_value(endpoints).is_none() && !endpoints.is_empty() {
166+
return Err(ConfigError::InvalidResponseHeaderValue { field });
167+
}
159168
Ok(())
160169
}
161170

162171
pub fn reporting_endpoints_header_value(endpoints: &BTreeMap<String, String>) -> Option<String> {
163172
if endpoints.is_empty() {
164173
return None;
165174
}
166-
Some(
167-
endpoints
168-
.iter()
169-
.map(|(name, endpoint)| format!("{name}=\"{endpoint}\""))
170-
.collect::<Vec<_>>()
171-
.join(", "),
172-
)
175+
let mut serialized = String::new();
176+
for (name, endpoint) in endpoints {
177+
if !valid_structured_field_key(name)
178+
|| !endpoint.starts_with("https://")
179+
|| !valid_http_endpoint_url(endpoint)
180+
{
181+
return None;
182+
}
183+
let endpoint = serialize_sf_string(endpoint)?;
184+
let separator_bytes = usize::from(!serialized.is_empty()) * 2;
185+
let additional_bytes = separator_bytes
186+
.checked_add(name.len())?
187+
.checked_add(1)?
188+
.checked_add(endpoint.len())?;
189+
if serialized.len().checked_add(additional_bytes)? > MAX_REPORTING_ENDPOINTS_HEADER_BYTES {
190+
return None;
191+
}
192+
if !serialized.is_empty() {
193+
serialized.push_str(", ");
194+
}
195+
serialized.push_str(name);
196+
serialized.push('=');
197+
serialized.push_str(&endpoint);
198+
}
199+
Some(serialized)
200+
}
201+
202+
fn valid_structured_field_key(name: &str) -> bool {
203+
if name.len() > MAX_REPORTING_ENDPOINT_NAME_BYTES {
204+
return false;
205+
}
206+
let mut bytes = name.bytes();
207+
matches!(bytes.next(), Some(b'a'..=b'z' | b'*'))
208+
&& bytes.all(|byte| {
209+
matches!(
210+
byte,
211+
b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'*'
212+
)
213+
})
214+
}
215+
216+
fn serialize_sf_string(value: &str) -> Option<String> {
217+
if !value.is_ascii() || value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
218+
return None;
219+
}
220+
let escaped_bytes = value
221+
.bytes()
222+
.filter(|byte| matches!(byte, b'"' | b'\\'))
223+
.count();
224+
let capacity = value.len().checked_add(escaped_bytes)?.checked_add(2)?;
225+
let mut serialized = String::with_capacity(capacity);
226+
serialized.push('"');
227+
for character in value.chars() {
228+
if matches!(character, '"' | '\\') {
229+
serialized.push('\\');
230+
}
231+
serialized.push(character);
232+
}
233+
serialized.push('"');
234+
Some(serialized)
173235
}
174236

175237
fn default_true() -> bool {

crates/fluxheim-config/src/config_tests_headers_response.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::super::*;
22
use crate::ResponseHeaderRewriteRuleConfig;
3+
use crate::config_header_hardening::reporting_endpoints_header_value;
34
use crate::{ResponseHardeningProfile, ResponsePermissionsPolicyConfig};
45

56
#[test]
@@ -368,3 +369,70 @@ fn rejects_invalid_reporting_endpoint_url() {
368369
})
369370
);
370371
}
372+
373+
#[test]
374+
fn rejects_invalid_reporting_endpoint_dictionary_members() {
375+
for name in ["CSP", "1csp", "csp+production"] {
376+
let mut config = Config::default();
377+
config.headers.response.reporting_endpoints.insert(
378+
name.to_owned(),
379+
"https://reports.example.test/csp".to_owned(),
380+
);
381+
assert!(matches!(
382+
config.validate(),
383+
Err(ConfigError::InvalidHeaderValue {
384+
field: "headers.response.reporting_endpoints",
385+
..
386+
})
387+
));
388+
}
389+
390+
for endpoint in [
391+
"http://reports.example.test/csp",
392+
"https://reports.example.test/csp-\u{00e5}",
393+
] {
394+
let mut config = Config::default();
395+
config
396+
.headers
397+
.response
398+
.reporting_endpoints
399+
.insert("csp".to_owned(), endpoint.to_owned());
400+
assert!(matches!(
401+
config.validate(),
402+
Err(ConfigError::InvalidHeaderValue {
403+
field: "headers.response.reporting_endpoints",
404+
..
405+
})
406+
));
407+
}
408+
}
409+
410+
#[test]
411+
fn serializes_reporting_endpoints_as_bounded_structured_fields() {
412+
let endpoints = std::collections::BTreeMap::from([
413+
(
414+
"csp".to_owned(),
415+
"https://reports.example.test/csp\"primary".to_owned(),
416+
),
417+
(
418+
"network-errors".to_owned(),
419+
"https://reports.example.test/network\\errors".to_owned(),
420+
),
421+
]);
422+
assert_eq!(
423+
reporting_endpoints_header_value(&endpoints).as_deref(),
424+
Some(
425+
"csp=\"https://reports.example.test/csp\\\"primary\", network-errors=\"https://reports.example.test/network\\\\errors\""
426+
)
427+
);
428+
429+
let oversized = (0..9)
430+
.map(|index| {
431+
(
432+
format!("endpoint-{index}"),
433+
format!("https://reports.example.test/{}", "a".repeat(2000)),
434+
)
435+
})
436+
.collect();
437+
assert_eq!(reporting_endpoints_header_value(&oversized), None);
438+
}

crates/fluxheim-server/src/native_http1_cors.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,15 @@ impl NativeCorsPolicy {
123123
}
124124

125125
pub(crate) fn response_origin(&self, request: &NativeHttp1Request) -> Option<String> {
126-
self.enabled
127-
.then(|| self.allowed_origin(request).map(str::to_owned))
128-
.flatten()
126+
if !self.enabled
127+
|| !self
128+
.allow_methods
129+
.iter()
130+
.any(|allowed| allowed.eq_ignore_ascii_case(&request.method))
131+
{
132+
return None;
133+
}
134+
self.allowed_origin(request).map(str::to_owned)
129135
}
130136

131137
pub(crate) fn apply_response_origin(

crates/fluxheim-server/src/native_http1_proxy_tests/header_policy.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use tokio::net::{TcpListener, TcpStream};
55

66
use crate::{DownstreamHttp1Policy, NativeHttp1Proxy, NativeHttp1Upstream};
77

8-
use super::{proxy_listener_for, upstream};
8+
use super::{counting_upstream, proxy_listener_for, upstream};
99

1010
#[tokio::test]
1111
async fn native_proxy_applies_header_policy() {
@@ -260,6 +260,48 @@ async fn native_route_proxy_handles_validated_cors_preflight_and_actual_request(
260260
assert!(!response.contains("access-control-max-age: 86400\r\n"));
261261
}
262262

263+
#[tokio::test]
264+
async fn native_route_proxy_enforces_cors_methods_on_actual_responses() {
265+
let (upstream, requests) = counting_upstream("ok", 2).await;
266+
let mut config = fluxheim_config::Config::default();
267+
config.proxy.upstreams = vec![upstream.to_string()];
268+
config.headers.cors = fluxheim_config::CorsPolicyConfig {
269+
enabled: true,
270+
allow_origins: vec!["https://app.example.test".to_owned()],
271+
allow_methods: vec!["POST".to_owned()],
272+
allow_headers: Vec::new(),
273+
expose_headers: Vec::new(),
274+
allow_credentials: true,
275+
max_age_secs: None,
276+
};
277+
config.headers.validate().unwrap();
278+
let proxy = crate::NativeHttp1RouteProxy::from_root_config(
279+
&config,
280+
DownstreamHttp1Policy::default(),
281+
0,
282+
)
283+
.unwrap();
284+
let proxy = route_proxy_listener(proxy).await;
285+
286+
let get = downstream_request(
287+
proxy,
288+
"GET /resource HTTP/1.1\r\nHost: proxy.test\r\nOrigin: https://app.example.test\r\nConnection: close\r\n\r\n",
289+
)
290+
.await;
291+
let post = downstream_request(
292+
proxy,
293+
"POST /resource HTTP/1.1\r\nHost: proxy.test\r\nOrigin: https://app.example.test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
294+
)
295+
.await;
296+
297+
assert!(get.starts_with("HTTP/1.1 200 OK\r\n"));
298+
assert!(!get.contains("access-control-allow-origin:"));
299+
assert!(!get.contains("access-control-allow-credentials:"));
300+
assert!(post.contains("access-control-allow-origin: https://app.example.test\r\n"));
301+
assert!(post.contains("access-control-allow-credentials: true\r\n"));
302+
assert_eq!(requests.load(std::sync::atomic::Ordering::Acquire), 2);
303+
}
304+
263305
async fn downstream_request(proxy: std::net::SocketAddr, request: &str) -> String {
264306
let mut client = TcpStream::connect(proxy).await.unwrap();
265307
client.write_all(request.as_bytes()).await.unwrap();

docs/config-reference.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,8 @@ profile = "deny-sensitive"
12271227
csp = "https://reports.example.com/csp"
12281228
```
12291229

1230-
Reporting endpoints are limited to 16 validated HTTP(S) URLs. Use report-only
1230+
Reporting endpoints are limited to 16 HTTPS URLs, use lowercase RFC 9651
1231+
dictionary keys, and have a bounded aggregate serialized size. Use report-only
12311232
CSP before enforcement, keep the endpoint on trusted infrastructure, and bound
12321233
its retention and ingestion independently. The generic response mutation layer
12331234
remains available for deliberately site-specific policies. For example,
@@ -1271,7 +1272,11 @@ locally with `204`; denied preflights receive `403` and do not reach the origin.
12711272
Fluxheim owns the resulting CORS headers, replaces unsafe upstream values, and
12721273
manages `Vary: Origin`, `Vary: Access-Control-Request-Method`, and
12731274
`Vary: Access-Control-Request-Headers`. Response compression separately manages
1274-
`Vary: Accept-Encoding`.
1275+
`Vary: Accept-Encoding`. Actual responses receive CORS authorization headers
1276+
only when both the request origin and request method are allowed. CORS controls
1277+
whether browser JavaScript may read a response; it is not CSRF protection and
1278+
must never replace route access policy, authentication, authorization, or
1279+
same-site cookie controls.
12751280

12761281
Generated rate-limit and concurrency-limit responses include
12771282
`Retry-After: 1`. Temporary PHP-FPM and ACME blocking-work saturation responses

packaging/rpm/fluxheim.spec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ fi
163163
handling while preserving operator-selected response policy.
164164
- Add opt-in response-hardening profiles, typed modern browser controls,
165165
validated request-aware CORS, automatic Vary, and bounded Retry-After.
166+
- Enforce actual-response CORS methods and strict Reporting-Endpoints encoding.
166167

167168
* Sun Jul 12 2026 Fluxheim Maintainers <1921261+eldryoth@users.noreply.github.com> - 1.7.9-1
168169
- Start documented and runnable Wasm migration-example parity work.

release-notes/RELEASE_NOTES_1.7.10.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ keeping the typed policy ABI constrained.
4141
cookies named `Domain` or `Path` are not mistaken for attributes.
4242
- Preserve closing quotes and trailing syntax while rewriting quoted exact-
4343
origin `Refresh` URLs.
44+
- Enforce CORS method allowlists on actual responses and serialize bounded
45+
Reporting-Endpoints dictionaries with strict keys and HTTPS collectors.
4446

4547
## Security
4648

0 commit comments

Comments
 (0)