Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
fde55b1
docs: design mobile ad-render trace endpoint
prk-Jr Sep 1, 2026
8899dca
docs: harden mobile ad trace design
prk-Jr Sep 1, 2026
534a691
Add server auction evidence to mobile trace design
prk-Jr Sep 1, 2026
20debdf
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 10, 2026
282d78b
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 14, 2026
c2aa6be
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 19, 2026
e3f371f
Reconcile mobile trace design contracts
prk-Jr Sep 19, 2026
488de2c
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 21, 2026
14c274c
Clarify mobile trace design contracts
prk-Jr Sep 21, 2026
7a82a94
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
aram356 Sep 24, 2026
155d3b0
Clarify trace body validation and browser cleanup
prk-Jr Sep 24, 2026
d361b8e
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 24, 2026
c4d664a
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
aram356 Sep 25, 2026
a180741
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Sep 30, 2026
4811d98
Clarify trace failure and export cleanup contracts
prk-Jr Oct 1, 2026
162b3a4
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Oct 1, 2026
2028bc6
Clarify mobile trace capture and truncation rules
prk-Jr Oct 2, 2026
20f4a0c
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
aram356 Oct 4, 2026
750ba8d
Add default-off mobile ad-rendering trace workflow
prk-Jr Oct 6, 2026
e076f77
Link trace release checklist to the repository plan
prk-Jr Oct 6, 2026
b2930d7
Preserve advertising behavior and trace controls
prk-Jr Oct 6, 2026
fb45d1c
Run skipped-page trace regression in CI
prk-Jr Oct 6, 2026
f6c3bd5
Record CodeQL test-fixture triage
prk-Jr Oct 6, 2026
b7e48c7
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Oct 6, 2026
6e9e7c4
Bound trace capture cost and complete review corrections
prk-Jr Oct 6, 2026
ec51817
Rewrite same-origin Origin to the upstream origin with --rewrite-host
prk-Jr Oct 7, 2026
e5becfc
Treat only commas that border reserved cookies as ambiguous
prk-Jr Oct 7, 2026
e1b062a
Lead the trace viewer with what happened and per-slot cards
prk-Jr Oct 7, 2026
4ac34aa
Scope the Origin rewrite to the /_ts namespace
prk-Jr Oct 7, 2026
6323a0e
Rewrite Origin only for the trace Enable/End actions
prk-Jr Oct 8, 2026
1dd5c7b
Describe the Origin rewrite as trace-action only
prk-Jr Oct 8, 2026
dc69eb4
Pin trace path classification across Fastly request conversion
prk-Jr Oct 8, 2026
285c75f
Merge main into spec/mobile-ad-render-trace-endpoint
prk-Jr Oct 8, 2026
fbc7372
Merge branch 'main' into fix/dev-proxy-rewrite-origin
prk-Jr Oct 8, 2026
49077c6
Merge branch 'main' into fix/dev-proxy-rewrite-origin
aram356 Oct 8, 2026
c0982a5
Merge branch 'main' into fix/dev-proxy-rewrite-origin
prk-Jr Oct 9, 2026
117373e
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
prk-Jr Oct 9, 2026
92dd203
Authenticate forwarded public origins before request dispatch
prk-Jr Oct 9, 2026
8d6ba17
Merge branch 'spec/mobile-ad-render-trace-endpoint' into fix/dev-prox…
prk-Jr Oct 9, 2026
ee024b8
Preserve browser Origin with authenticated proxy forwarding
prk-Jr Oct 9, 2026
cf36844
Document random forwarder tokens and temporary digest comparison
prk-Jr Oct 9, 2026
720ae96
Merge branch 'spec/mobile-ad-render-trace-endpoint' into fix/dev-prox…
prk-Jr Oct 9, 2026
b01b2e2
Clarify random proxy token provisioning and security review
prk-Jr Oct 9, 2026
ba227cb
Initialize proxy TLS before startup
prk-Jr Oct 9, 2026
5c66d64
Merge branch 'main' into spec/mobile-ad-render-trace-endpoint
aram356 Oct 11, 2026
b4a20f4
Address trace workflow review findings
prk-Jr Oct 12, 2026
3993b72
Merge trace base into dev proxy origin fix
prk-Jr Oct 12, 2026
29ff56c
Merge main and preserve trace asset verification
prk-Jr Oct 15, 2026
ad9b768
Address trace build provenance and dispatch review findings
prk-Jr Oct 15, 2026
d821fb3
Update EdgeZero to reviewed request ingress revision
prk-Jr Oct 15, 2026
ba06c37
Forward Cloudflare fixture readiness through shared spawn helper
prk-Jr Oct 15, 2026
944c3eb
Merge main and preserve EdgeZero hook revision
prk-Jr Oct 19, 2026
bccb1bf
Resolve trace asset and authenticated client IP review findings
prk-Jr Oct 19, 2026
6cdcda4
Merge updated trace base into dev proxy origin fix
prk-Jr Oct 19, 2026
f9a426c
Address authenticated dev proxy review findings
prk-Jr Oct 19, 2026
8862979
Merge main into dev proxy origin fix
prk-Jr Oct 30, 2026
96c67ea
Use a reserved example host in the Origin preservation test
prk-Jr Oct 30, 2026
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
2 changes: 2 additions & 0 deletions 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 crates/trusted-server-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ x509-parser = { workspace = true }

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
assert_cmd = { workspace = true }
edgezero-adapter-axum = { workspace = true, features = ["axum"] }
flate2 = { workspace = true }
predicates = { workspace = true }
temp-env = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" }
3 changes: 3 additions & 0 deletions crates/trusted-server-cli/src/commands/dev/proxy/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ mod tests {

#[test]
fn leaf_cache_returns_same_arc_for_same_host() {
crate::tls::install_crypto_provider();
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
let a = ca
Expand All @@ -322,6 +323,7 @@ mod tests {

#[test]
fn leaf_cache_normalizes_dns_case() {
crate::tls::install_crypto_provider();
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
let lower = ca
Expand All @@ -335,6 +337,7 @@ mod tests {

#[test]
fn mints_leaf_for_ip_literal_host() {
crate::tls::install_crypto_provider();
// An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3.
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
Expand Down
231 changes: 230 additions & 1 deletion crates/trusted-server-cli/src/commands/dev/proxy/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use hyper::header::HeaderValue;

use super::ProxyArgs;
use super::rewrite::{Authority, Rule, RuleTable};
use super::upstream::key::AddressPolicy;
use super::upstream::key::{AddressPolicy, ReferenceIdentity, Transport, VerifyMode};

/// Errors from configuration resolution.
#[derive(Debug, derive_more::Display)]
Expand Down Expand Up @@ -46,6 +46,15 @@ pub enum ConfigError {
can reach the proxy. Bind a loopback address, or drop --basic-auth."
)]
BasicAuthNonLoopback { value: String },
/// The forwarding token file could not be read.
#[display("cannot read --forwarder-secret-file `{path}`")]
ForwarderSecretFile { path: String },
/// The forwarding token was invalid.
#[display("--forwarder-secret-file must contain at least 32 ASCII graphic bytes on one line")]
ForwarderSecret,
/// Credential injection requires a loopback listener.
#[display("--forwarder-secret-file requires a loopback --listen address")]
ForwarderSecretNonLoopback,
/// An unknown or unsupported browser was passed to `--launch`.
#[display("unsupported browser `{value}` (use chrome|firefox|all, plus safari on macOS)")]
Browser { value: String },
Expand Down Expand Up @@ -91,6 +100,41 @@ impl core::fmt::Debug for BasicAuth {
}
}

/// Authentication header shared with Trusted Server's trusted-forwarder configuration.
pub use trusted_server_core::forwarder::FORWARDER_AUTH_HEADER;

/// A validated forwarding token whose debug representation is redacted.
#[derive(Clone)]
pub struct ForwarderSecret(HeaderValue);

impl ForwarderSecret {
fn parse(raw: &[u8]) -> Result<Self, ConfigError> {
let token = raw
.strip_suffix(b"\r\n")
.or_else(|| raw.strip_suffix(b"\n"))
.unwrap_or(raw);
if token.len() < 32 || !token.iter().all(u8::is_ascii_graphic) {
return Err(ConfigError::ForwarderSecret);
}
let mut header =
HeaderValue::from_bytes(token).map_err(|_| ConfigError::ForwarderSecret)?;
header.set_sensitive(true);
Ok(Self(header))
}

/// The prevalidated sensitive authentication header value.
#[must_use]
pub fn header_value(&self) -> &HeaderValue {
&self.0
}
}

impl core::fmt::Debug for ForwarderSecret {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str("ForwarderSecret([REDACTED])")
}
}

/// A browser the proxy can launch and configure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Browser {
Expand Down Expand Up @@ -143,6 +187,8 @@ pub struct ResolvedConfig {
pub launch: Vec<Browser>,
pub insecure: bool,
pub basic_auth: Option<BasicAuth>,
/// Optional credential for authenticating browser-facing forwarding metadata.
pub forwarder_secret: Option<ForwarderSecret>,
pub ca_dir: PathBuf,
/// DNS pins from `--resolve`: lowercase hostname → connection address. When
/// an upstream host is present here, the proxy dials this IP instead of
Expand Down Expand Up @@ -309,6 +355,19 @@ pub fn resolve(args: &ProxyArgs) -> Result<ResolvedConfig, Report<ConfigError>>
value: args.listen.clone(),
}));
}
if !is_loopback && args.forwarder_secret_file.is_some() {
Comment thread
prk-Jr marked this conversation as resolved.
Resolved
return Err(Report::new(ConfigError::ForwarderSecretNonLoopback));
}
let forwarder_secret = args
.forwarder_secret_file
.as_ref()
.map(|path| {
let raw = std::fs::read(path)
.map_err(|_| ConfigError::ForwarderSecretFile { path: path.clone() })?;
ForwarderSecret::parse(&raw)
})
.transpose()
.map_err(Report::from)?;
let ca_dir = ca_dir(args);
let resolve = build_resolve(args).map_err(Report::from)?;

Expand All @@ -318,6 +377,13 @@ pub fn resolve(args: &ProxyArgs) -> Result<ResolvedConfig, Report<ConfigError>>
}
}

if forwarder_upstream_warning_needed(&rules, forwarder_secret.as_ref()) {
log::warn!(
"--forwarder-secret-file token crosses a cleartext or unverified upstream connection \
outside loopback; use verified TLS or a loopback destination"
);
}

// A `--resolve HOST:IP` whose HOST matches no rule's TO host is almost
// certainly a typo: the pin would silently never apply. Warn rather than
// error, so a deliberate pin for a host reached indirectly still works.
Expand All @@ -336,12 +402,31 @@ pub fn resolve(args: &ProxyArgs) -> Result<ResolvedConfig, Report<ConfigError>>
launch,
insecure: args.insecure,
basic_auth,
forwarder_secret,
ca_dir,
resolve,
connect_timeout: std::time::Duration::from_secs(args.connect_timeout),
})
}

fn forwarder_upstream_warning_needed(
rules: &RuleTable,
forwarder_secret: Option<&ForwarderSecret>,
) -> bool {
forwarder_secret.is_some() && rules.0.iter().any(|rule| {
let key = rule.origin_key();
let unprotected =
key.transport() == Transport::Plaintext || key.verify_mode() == VerifyMode::Insecure;
let loopback = match key.address_policy() {
AddressPolicy::Resolve(address) => address.is_loopback(),
AddressPolicy::Dns => {
matches!(key.reference(), ReferenceIdentity::Ip(address) if address.is_loopback())
}
};
unprotected && !loopback
})
}

/// Credential precedence: `--basic-auth-file` > `--basic-auth`.
fn resolve_basic_auth(args: &ProxyArgs) -> Result<Option<BasicAuth>, ConfigError> {
if let Some(path) = &args.basic_auth_file {
Expand Down Expand Up @@ -384,6 +469,150 @@ mod tests {
W::try_parse_from(argv).map(|w| w.a)
}

#[test]
fn forwarder_secret_file_is_accepted_by_cli() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
std::fs::write(&path, "example-forwarder-token-0123456789\n").expect("should write token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
assert!(
resolve(&args).is_ok(),
"should load a valid forwarder secret"
);
}

#[test]
fn forwarder_secret_files_reject_invalid_contents_without_disclosure() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
for raw in [
"",
"example-short-token-0123456789",
"1234567890123456789012345678901",
"example-forwarder-token-0123456789 ",
" example-forwarder-token-0123456789",
"example-forwarder-token-0123456789\n\n",
"example-forwarder-token-0123456789\r",
"example-forwarder-token-0123456789\nsecond",
"example-forwarder-token-0123456789é",
] {
std::fs::write(&path, raw).expect("should write invalid token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
let error = resolve(&args).expect_err("should reject invalid token");
if !raw.is_empty() {
assert!(
!format!("{error:?}").contains(raw),
"should redact invalid token"
);
}
}
}

#[test]
fn non_loopback_rejects_forwarder_secret_file() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
std::fs::write(&path, "example-forwarder-token-0123456789").expect("should write token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
"--listen",
"0.0.0.0:18080",
"--allow-non-loopback",
]);
let error = resolve(&args).expect_err("should reject credential injection off loopback");
assert!(
matches!(
error.current_context(),
ConfigError::ForwarderSecretNonLoopback
),
"should reject credential injection before reading the file"
);
}

#[test]
fn forwarder_credentials_are_optional_and_accept_exactly_32_bytes() {
let args = parse_args(&["ts", "--map", "a.example.com=b.example.com"]);
assert!(
resolve(&args)
.expect("should resolve default config")
.forwarder_secret
.is_none(),
"should leave forwarding authentication disabled by default"
);
assert!(
ForwarderSecret::parse(b"12345678901234567890123456789012").is_ok(),
"should accept the minimum token length"
);
}

#[test]
fn forwarding_token_is_sensitive_and_debug_redacted() {
for ending in ["", "\n", "\r\n"] {
let raw = format!("example-forwarder-token-0123456789{ending}");
let secret = ForwarderSecret::parse(raw.as_bytes()).expect("should accept token");
assert!(
secret.header_value().is_sensitive(),
"should mark credential sensitive"
);
assert_eq!(
secret.header_value(),
"example-forwarder-token-0123456789",
"should remove only line terminator"
);
assert!(
!format!("{secret:?}").contains("example-forwarder"),
"should redact wrapper Debug"
);
assert!(
!format!("{:?}", secret.header_value()).contains("example-forwarder"),
"should redact header Debug"
);
}
}

#[test]
fn forwarder_secret_file_read_failure_reports_path() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("missing");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
let error = resolve(&args).expect_err("should reject missing token file");
assert!(
matches!(
error.current_context(),
ConfigError::ForwarderSecretFile { .. }
),
"should report file error"
);
assert!(
error
.to_string()
.contains(path.to_str().expect("should encode missing token path")),
"should identify the unreadable token file without disclosing token contents"
);
}

#[test]
fn clap_parses_rewrite_host_as_a_bool() {
assert!(
Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-cli/src/commands/dev/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ pub struct ProxyArgs {
#[arg(long, value_name = "PATH")]
pub basic_auth_file: Option<String>,

/// Read the Trusted Server forwarding token from a file (loopback only).
#[arg(long, value_name = "PATH")]
pub forwarder_secret_file: Option<String>,

/// Skip upstream certificate verification.
#[arg(long)]
pub insecure: bool,
Expand Down Expand Up @@ -251,6 +255,8 @@ pub fn run(args: &ProxyArgs) -> core::result::Result<(), error_stack::Report<Pro
// nor changes system proxy state, so it is safe to run first.
let mut cfg = config::resolve(args).change_context(ProxyError::Config)?;

crate::tls::install_crypto_provider();

// Non-interactive so an unrelated startup never blocks on a sudo password
// prompt.
browser::restore_system_proxy_if_pending(&cfg.ca_dir, false);
Expand Down
Loading
Loading