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
20 changes: 12 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build-prepare/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ferron-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ version = "2.4.1"
edition = "2021"

[dependencies]
anyhow = "1.0.98"
fancy-regex = "0.17.0"
regex = "1.12.3"
hyper = { version = "1.6.0", features = ["full"] }
cidr = "0.3.1"
async-trait = "0.1.88"
Expand Down
214 changes: 214 additions & 0 deletions ferron-common/src/util/config_placeholders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
use std::env;
use anyhow::{bail, Result};

fn resolve_placeholder(kind: &str, value: &str) -> Result<Option<String>> {
match kind {
"env" => match env::var(value) {
Ok(val) => Ok(Some(val)),
Err(e) => bail!("couldn't find ENV-Key >>{}<< {}", value, e),
},
_ => Ok(None),
}
}

pub fn replace_placeholders(input: &str) -> Result<String> {
let mut output = String::new();
let mut cursor = 0;

while cursor < input.len() {
let next = input[cursor..].find('{');

let start_rel = match next {
Some(pos) => pos,
None => {
output.push_str(&input[cursor..]);
break;
}
};

let start = cursor + start_rel;

// Check if escaped: "\{"
if start > 0 && input.as_bytes()[start - 1] == b'\\' {
// push everything before the backslash
output.push_str(&input[cursor..start - 1]);
// push literal '{'
output.push('{');

cursor = start + 1;
continue;
}

let end_rel = match input[start + 1..].find('}') {
Some(pos) => pos,
None => bail!("No closing '}}' found for '{{' at position {}", start),
};

let end = start + 1 + end_rel;

// Push preceding text
output.push_str(&input[cursor..start]);

let placeholder = &input[start + 1..end];

if let Some((kind, value)) = placeholder.split_once(':') {
match resolve_placeholder(kind, value)? {
Some(resolved) => output.push_str(&resolved),
None => {
output.push('{');
output.push_str(placeholder);
output.push('}');
}
}
} else {
output.push('{');
output.push_str(placeholder);
output.push('}');
}

cursor = end + 1;
}

Ok(output)
}

#[cfg(test)]
mod tests {
use super::*;
use std::env;

#[test]
fn env_var_missing() {
let result = resolve_placeholder("env", "LALA_SHOULD_NOT_EXIST");
assert!(result.is_err());
}

#[test]
fn env_var_exists() {
env::set_var("TEST_ENV_EXISTS", "value");

let result = resolve_placeholder("env", "TEST_ENV_EXISTS");
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some("value".to_string()));
}

#[test]
fn passthrough_no_placeholders() {
let input = "LALA";
let result = replace_placeholders(input);
assert!(result.is_ok());
assert_eq!(result.unwrap(), input);
}

#[test]
fn single_env_placeholder() {
env::set_var("TEST_HOME", "/home/test");

let result = replace_placeholders("{env:TEST_HOME}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/home/test");
}

#[test]
fn unknown_kind_passthrough() {
let result = replace_placeholders("{envA:HOME}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "{envA:HOME}");
}

#[test]
fn interpolate_env_with_suffix() {
env::set_var("TEST_HOME", "/home/test");

let result = replace_placeholders("{env:TEST_HOME}/src/modules");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/home/test/src/modules");
}

#[test]
fn interpolate_multiple_env_values() {
env::set_var("TEST_HOME", "/home/test");
env::set_var("TEST_USER", "user");

let input = "prefix_{env:TEST_HOME}_middle_{env:TEST_USER}_suffix";
let result = replace_placeholders(input).unwrap();

let expected = "prefix_/home/test_middle_user_suffix";
assert_eq!(result, expected);
}

#[test]
fn plain_string_passthrough() {
let input = "plain_string_without_env";
let result = replace_placeholders(input);
assert!(result.is_ok());
assert_eq!(result.unwrap(), input);
}

#[test]
fn missing_closing_brace() {
let result = replace_placeholders("{env:TEST_HOME");
assert!(result.is_err());
}

#[test]
fn nonexistent_env_var() {
let result =
replace_placeholders("{env:THIS_SHOULD_NOT_EXIST_123}");
assert!(result.is_err());
}

#[test]
fn nonexistent_env_var_in_interpolation() {
let result =
replace_placeholders("prefix_{env:THIS_SHOULD_NOT_EXIST_456}_suffix");
assert!(result.is_err());
}

#[test]
fn placeholder_without_colon_passthrough() {
let result = replace_placeholders("{justtext}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "{justtext}");
}

#[test]
fn escaped_open_brace() {
// No env needed — should NOT resolve
let result = replace_placeholders(r"\{env:TEST_HOME}").unwrap();
assert_eq!(result, "{env:TEST_HOME}");
}

#[test]
fn escaped_brace_with_text() {
let result = replace_placeholders(r"prefix_\{env:TEST_HOME}_suffix").unwrap();
assert_eq!(result, "prefix_{env:TEST_HOME}_suffix");
}

#[test]
fn escaped_and_real_placeholder() {
std::env::set_var("TEST_HOME_ESC", "/home/test");

let input = r"\{env:TEST_HOME_ESC}_{env:TEST_HOME_ESC}";
let result = replace_placeholders(input).unwrap();

assert_eq!(result, "{env:TEST_HOME_ESC}_/home/test");
}

#[test]
fn double_escape_sequence() {
std::env::set_var("TEST_HOME_ESC2", "/home/test");

// "\\{" → literal "\" + "{"
let result = replace_placeholders(r"\\{env:TEST_HOME_ESC2}").unwrap();

// First "\" is literal, then placeholder is evaluated
assert_eq!(result, r"\/home/test");
}

#[test]
fn escaped_non_placeholder() {
let result = replace_placeholders(r"\{justtext}").unwrap();
assert_eq!(result, "{justtext}");
}
}
2 changes: 2 additions & 0 deletions ferron-common/src/util/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod anti_xss;
mod config_macros;
mod default_html_page;
mod config_placeholders;
mod header_placeholders;
mod ip_blocklist;
mod is_localhost;
Expand All @@ -22,6 +23,7 @@ pub use anti_xss::*;
pub use header_placeholders::*;
pub use ip_blocklist::*;
pub use is_localhost::*;
pub use config_placeholders::*;
pub use match_hostname::*;
pub use match_location::*;
pub use module_cache::*;
Expand Down
7 changes: 5 additions & 2 deletions ferron/src/config/adapters/kdl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
str::FromStr,
};

use ferron_common::observability::ObservabilityBackendChannels;
use ferron_common::{observability::ObservabilityBackendChannels, util::replace_placeholders};
use glob::glob;
use kdl::{KdlDocument, KdlNode, KdlValue};

Expand All @@ -23,7 +23,10 @@ fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEnt
let mut props = HashMap::new();
for kdl_entry in kdl_node.iter() {
let value = match kdl_entry.value().to_owned() {
KdlValue::String(value) => ServerConfigurationValue::String(value),
KdlValue::String(value) => {
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.

It will probably be best to render all placeholders first and then process the config files. Will this config work?

{env.HTTP_ADDRESS} {
    proxy "http://localhost:3000/" // Replace "http://localhost:3000" with the backend server URL
}

let resolved_value = replace_placeholders(&value).expect("Failed to resolve environment variable in configuration");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If environment variable wouldn't be found (see https://github.com/ferronweb/ferron/pull/425/changes#diff-63c5becf3cec064da1306878c6a2ffe08a0e70be83a1d6c801b59e2b268339abR8), the web server would crash... Could this be handled more gracefully, like it's in:

pub fn replace_header_placeholders(
input: &str,
request_parts: &hyper::http::request::Parts,
socket_data: Option<&SocketData>,
) -> String {
let mut output = String::new();
let mut index_rb_saved = 0;
loop {
let index_lb = input[index_rb_saved..].find("{");
if let Some(index_lb) = index_lb {
let index_rb_afterlb = input[index_rb_saved + index_lb + 1..].find("}");
if let Some(index_rb_afterlb) = index_rb_afterlb {
let index_rb = index_rb_afterlb + index_lb + 1;
let placeholder_value = &input[index_rb_saved + index_lb + 1..index_rb_saved + index_rb];
output.push_str(&input[index_rb_saved..index_rb_saved + index_lb]);
match placeholder_value {
"path" => output.push_str(request_parts.uri.path()),
"path_and_query" => output.push_str(
request_parts
.uri
.path_and_query()
.map_or(request_parts.uri.path(), |p| p.as_str()),
),
"method" => output.push_str(request_parts.method.as_str()),
"version" => output.push_str(match request_parts.version {
hyper::Version::HTTP_09 => "HTTP/0.9",
hyper::Version::HTTP_10 => "HTTP/1.0",
hyper::Version::HTTP_11 => "HTTP/1.1",
hyper::Version::HTTP_2 => "HTTP/2.0",
hyper::Version::HTTP_3 => "HTTP/3.0",
_ => "HTTP/Unknown",
}),
"scheme" => {
if let Some(socket_data) = socket_data {
output.push_str(if socket_data.encrypted { "https" } else { "http" });
} else {
// No socket data, leave it as is
output.push_str("{scheme}");
}
}
"client_ip" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.remote_addr.ip().to_string());
} else {
// No socket data, leave it as is
output.push_str("{client_ip}");
}
}
"client_port" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.remote_addr.port().to_string());
} else {
// No socket data, leave it as is
output.push_str("{client_port}");
}
}
"client_ip_canonical" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.remote_addr.ip().to_canonical().to_string());
} else {
// No socket data, leave it as is
output.push_str("{client_ip_canonical}");
}
}
"server_ip" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.local_addr.ip().to_string());
} else {
// No socket data, leave it as is
output.push_str("{server_ip}");
}
}
"server_port" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.local_addr.port().to_string());
} else {
// No socket data, leave it as is
output.push_str("{server_port}");
}
}
"server_ip_canonical" => {
if let Some(socket_data) = socket_data {
output.push_str(&socket_data.local_addr.ip().to_canonical().to_string());
} else {
// No socket data, leave it as is
output.push_str("{server_ip_canonical}");
}
}
_ => {
if let Some(header_name) = placeholder_value.strip_prefix("header:") {
if let Some(header_value) = request_parts.headers.get(header_name) {
output.push_str(header_value.to_str().unwrap_or(""));
}
} else {
// Unknown placeholder, leave it as is
output.push('{');
output.push_str(placeholder_value);
output.push('}');
}
}
}
if index_rb < input.len() - 1 {
index_rb_saved += index_rb + 1;
} else {
break;
}
} else {
output.push_str(&input[index_rb_saved..]);
}
} else {
output.push_str(&input[index_rb_saved..]);
break;
}
}
output
}

ServerConfigurationValue::String(resolved_value)
}
KdlValue::Integer(value) => ServerConfigurationValue::Integer(value),
KdlValue::Float(value) => ServerConfigurationValue::Float(value),
KdlValue::Bool(value) => ServerConfigurationValue::Bool(value),
Expand Down