Skip to content

Commit 17aa33f

Browse files
committed
Harden snapshots and header templates
1 parent b3709ca commit 17aa33f

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

src/headers.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ fn render_header_template(value: &str, context: &RequestHeaderTemplateContext) -
300300
};
301301
let variable = &after_open[..close];
302302
if let Some(value) = context.variable(variable) {
303-
rendered.push_str(value);
303+
push_header_template_variable(&mut rendered, value);
304304
}
305305
rest = &after_open[close + 1..];
306306
}
@@ -309,6 +309,10 @@ fn render_header_template(value: &str, context: &RequestHeaderTemplateContext) -
309309
rendered
310310
}
311311

312+
fn push_header_template_variable(rendered: &mut String, value: &str) {
313+
rendered.extend(value.chars().filter(|character| !character.is_control()));
314+
}
315+
312316
fn apply_response_mutations(
313317
response: &mut pingora::http::ResponseHeader,
314318
unset: &[String],
@@ -853,6 +857,36 @@ mod tests {
853857
}
854858
}
855859

860+
#[cfg(not(feature = "privacy-mode"))]
861+
#[test]
862+
fn dynamic_header_rendering_strips_control_characters_from_variables() {
863+
let context = RequestHeaderTemplateContext {
864+
headers: ::http::HeaderMap::new(),
865+
host: Some("example.test".to_owned()),
866+
remote_addr: Some("203.0.113.10\r\nx-injected: true".to_owned()),
867+
scheme: "https",
868+
uri: "/chat/\r\nbad".to_owned(),
869+
path: "/chat/\u{7f}bad".to_owned(),
870+
query: "room=main\tadmin=false".to_owned(),
871+
request_id: Some("req-123\nbad".to_owned()),
872+
};
873+
874+
let rendered = render_header_template(
875+
"{host} {remote_addr} {scheme} {uri} {path} {query} {request_id}",
876+
&context,
877+
);
878+
879+
assert_eq!(
880+
rendered,
881+
"example.test 203.0.113.10x-injected: true https /chat/bad /chat/bad room=mainadmin=false req-123bad"
882+
);
883+
assert!(
884+
rendered
885+
.bytes()
886+
.all(|byte| !matches!(byte, 0x00..=0x1f | 0x7f))
887+
);
888+
}
889+
856890
#[cfg(not(feature = "privacy-mode"))]
857891
#[test]
858892
fn applies_user_friendly_request_header_operations() {

src/snapshot.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ impl SnapshotStore {
7575
message: Option<&str>,
7676
) -> Result<ConfigSnapshot, SnapshotError> {
7777
self.validate_root()?;
78+
self.ensure_store_capacity()?;
7879
config.validate().map_err(|error| {
7980
SnapshotError::Config(crate::config::ConfigLoadError::Validate(error))
8081
})?;
@@ -260,6 +261,39 @@ impl SnapshotStore {
260261
Ok(())
261262
}
262263

264+
fn ensure_store_capacity(&self) -> Result<(), SnapshotError> {
265+
if !self.safe_existing_root()? || !self.safe_existing_configs_dir()? {
266+
return Ok(());
267+
}
268+
269+
let mut snapshots = 0_usize;
270+
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
271+
let entry = entry.map_err(SnapshotError::Io)?;
272+
let path = entry.path();
273+
if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
274+
continue;
275+
}
276+
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
277+
continue;
278+
};
279+
if stem.ends_with(".meta") {
280+
continue;
281+
}
282+
snapshots = snapshots.saturating_add(1);
283+
if snapshots >= MAX_SNAPSHOT_STORE_ENTRIES {
284+
return Err(SnapshotError::Io(io::Error::new(
285+
io::ErrorKind::StorageFull,
286+
format!(
287+
"snapshot store {} reached the {} snapshot limit",
288+
self.root.display(),
289+
MAX_SNAPSHOT_STORE_ENTRIES
290+
),
291+
)));
292+
}
293+
}
294+
Ok(())
295+
}
296+
263297
fn validate_root(&self) -> Result<(), SnapshotError> {
264298
if self.root.as_os_str().is_empty()
265299
|| self
@@ -755,6 +789,44 @@ mod tests {
755789
);
756790
}
757791

792+
#[test]
793+
fn snapshot_config_rejects_full_snapshot_store_before_writing() {
794+
let dir = TestDir::new("snapshot-capacity-before-write");
795+
let store = SnapshotStore::new(dir.path());
796+
let configs = dir.child("configs");
797+
std::fs::create_dir(&configs).unwrap();
798+
for index in 0..super::MAX_SNAPSHOT_STORE_ENTRIES {
799+
std::fs::write(safe_child_path(&configs, &format!("s{index:04}.toml")), b"").unwrap();
800+
}
801+
802+
let error = store
803+
.snapshot_config(&Config::default(), Some("should not write"))
804+
.unwrap_err();
805+
806+
assert!(
807+
matches!(error, SnapshotError::Io(error) if error.kind() == std::io::ErrorKind::StorageFull)
808+
);
809+
let created_configs = std::fs::read_dir(&configs)
810+
.unwrap()
811+
.filter(|entry| {
812+
entry
813+
.as_ref()
814+
.ok()
815+
.and_then(|entry| {
816+
entry
817+
.path()
818+
.extension()
819+
.and_then(|value| value.to_str())
820+
.map(str::to_owned)
821+
})
822+
.as_deref()
823+
== Some("toml")
824+
})
825+
.count();
826+
assert_eq!(created_configs, super::MAX_SNAPSHOT_STORE_ENTRIES);
827+
assert!(store.current_id().unwrap().is_none());
828+
}
829+
758830
#[test]
759831
fn current_snapshot_loads_current_pointer() {
760832
let dir = TestDir::new("snapshot-current-load");

0 commit comments

Comments
 (0)