use std::collections::HashMap;
pub fn resolve_field(
field: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
match field {
"match" => credential.to_string(),
s if s.starts_with("companion.") => {
let name = &s["companion.".len()..];
companions.get(name).cloned().unwrap_or_default()
}
"" => String::new(),
other => other.to_string(),
}
}
fn url_encode(s: &str) -> String {
percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string()
}
fn sanitize_raw_value(s: &str) -> String {
s.chars()
.filter(|c| {
let cp = *c as u32;
!(cp < 0x20 && cp != 0x09) && cp != 0x7F && !(0x80..=0x9F).contains(&cp)
})
.collect()
}
pub fn interpolate(
template: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
const MAX_INTERPOLATION_REPLACEMENTS: usize = 1024;
if template == "{{match}}" {
return sanitize_raw_value(credential);
}
if template.starts_with("{{companion.")
&& template.ends_with("}}")
&& template.matches("{{").count() == 1
{
let name = &template["{{companion.".len()..template.len() - 2];
return sanitize_raw_value(companions.get(name).map(String::as_str).unwrap_or(""));
}
let mut interpolated = template.replace("{{match}}", &url_encode(credential));
for (token, key) in [
("{{interactsh.url}}", "__keyhog_oob_url"),
("{{interactsh.host}}", "__keyhog_oob_host"),
("{{interactsh.id}}", "__keyhog_oob_id"),
("{{interactsh}}", "__keyhog_oob_host"),
] {
if interpolated.contains(token) {
let value = companions.get(key).map(String::as_str).unwrap_or("");
interpolated = interpolated.replace(token, value);
}
}
let mut search_from = 0;
let mut replacements = 0usize;
while replacements < MAX_INTERPOLATION_REPLACEMENTS {
let Some(offset) = interpolated[search_from..].find("{{companion.") else {
break;
};
let start = search_from + offset;
if let Some(end_offset) = interpolated[start..].find("}}") {
let name_start = start + "{{companion.".len();
let name_end = start + end_offset;
let name = &interpolated[name_start..name_end];
let replacement = url_encode(companions.get(name).map(String::as_str).unwrap_or(""));
let end = start + end_offset + 2;
interpolated = format!(
"{}{}{}",
&interpolated[..start],
replacement,
&interpolated[end..]
);
search_from = (start + replacement.len()).min(interpolated.len());
replacements += 1;
} else {
break;
}
}
interpolated
}
pub const OOB_COMPANION_URL: &str = "__keyhog_oob_url";
pub const OOB_COMPANION_HOST: &str = "__keyhog_oob_host";
pub const OOB_COMPANION_ID: &str = "__keyhog_oob_id";
pub fn companions_with_oob(
base: &HashMap<String, String>,
minted_host: &str,
minted_url: &str,
minted_id: &str,
) -> HashMap<String, String> {
let mut out = base.clone();
out.insert(OOB_COMPANION_HOST.to_string(), minted_host.to_string());
out.insert(OOB_COMPANION_URL.to_string(), minted_url.to_string());
out.insert(OOB_COMPANION_ID.to_string(), minted_id.to_string());
out
}
#[cfg(test)]
mod oob_tests {
use super::*;
use std::collections::HashMap;
fn oob_companions() -> HashMap<String, String> {
let base = HashMap::new();
companions_with_oob(
&base,
"abc123def456ghi789jkl0mnopqrstuv1.oast.fun",
"https://abc123def456ghi789jkl0mnopqrstuv1.oast.fun",
"abc123def456ghi789jkl0mnopqrstuv1",
)
}
#[test]
fn interactsh_bare_substitutes_host() {
let c = oob_companions();
let out = interpolate("https://{{interactsh}}/x", "credential", &c);
assert_eq!(out, "https://abc123def456ghi789jkl0mnopqrstuv1.oast.fun/x");
}
#[test]
fn interactsh_url_substitutes_full_url() {
let c = oob_companions();
let out = interpolate("{\"callback\":\"{{interactsh.url}}\"}", "credential", &c);
assert!(out.contains("https://abc123def456ghi789jkl0mnopqrstuv1.oast.fun"));
assert!(!out.contains("{{interactsh"));
}
#[test]
fn interactsh_id_substitutes_correlation_id_only() {
let c = oob_companions();
let out = interpolate("oob_id={{interactsh.id}}", "credential", &c);
assert_eq!(out, "oob_id=abc123def456ghi789jkl0mnopqrstuv1");
}
#[test]
fn interactsh_token_with_no_value_collapses_to_empty() {
let empty = HashMap::new();
let out = interpolate("https://{{interactsh}}/x", "credential", &empty);
assert_eq!(out, "https:///x");
}
#[test]
fn interactsh_does_not_url_encode_host() {
let c = oob_companions();
let out = interpolate("host={{interactsh}}", "x", &c);
assert!(out.contains("oast.fun"));
assert!(!out.contains("%2E"));
}
}