Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: CI

on:
Expand Down Expand Up @@ -66,11 +66,13 @@
- name: Run workspace tests
# The LSAPI supervisor suite signals and reaps detached descendants.
# GitHub's runner blocks those operations for its unprivileged user.
# sudo may also reset RLIMIT_NOFILE to 1024, but the TCP handoff test
# deliberately holds 1024 queued descriptors plus its control sockets.
run: |
cargo_bin="$(rustup which cargo)"
rustc_bin="$(rustup which rustc)"
rustdoc_bin="$(rustup which rustdoc)"
sudo env \
sudo prlimit --nofile=65536:65536 -- env \
"HOME=$HOME" \
"CARGO_HOME=$HOME/.cargo" \
"PATH=$PATH" \
Expand Down
4 changes: 4 additions & 0 deletions crates/hj-h2/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,11 @@ pub(super) struct OutQueue {
mod completion_tests;

impl OutQueue {
#[inline]
fn finish_responses(&mut self, success: bool) {
if self.completions.is_empty() {
return;
}
for completion in self.completions.drain(..) {
completion.finish(if success {
hj_core::ResponseEnd::Complete
Expand Down
21 changes: 17 additions & 4 deletions crates/hj-h2/src/server/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,14 @@ pub(super) fn begin_response(
block_scratch: &mut Vec<u8>,
) {
let (mut head, body) = response.into_parts();
let mut completion = head.extensions.remove::<hj_core::ResponseCompletion>();
// ResponseCompletion is opt-in (currently OpenTelemetry). The normal
// production response has no extensions, so avoid a TypeId/hash-table
// lookup on every H2 response.
let mut completion = if head.extensions.is_empty() {
None
} else {
head.extensions.remove::<hj_core::ResponseCompletion>()
};
// §8.2.2: connection-specific ("hop-by-hop") fields are illegal on an h2 response — strip
// them before encoding so a backend that emits e.g. `Connection`/`Transfer-Encoding`
// (PHP over LSAPI, a proxied upstream) can't produce a malformed frame stream.
Expand Down Expand Up @@ -223,7 +230,9 @@ pub(super) fn begin_response(

let headers_only = |out: &mut OutQueue, completion: Option<hj_core::ResponseCompletion>| {
out.frames(|b| write_field_block(b, stream_id, flags::END_STREAM, block, mf));
out.completions.extend(completion);
if let Some(completion) = completion {
out.completions.push(completion);
}
};
let headers_open = |out: &mut OutQueue| {
out.frames(|b| write_field_block(b, stream_id, 0, block, mf));
Expand Down Expand Up @@ -490,7 +499,9 @@ fn pump_one_frame(
st.window -= n as i64;
if last {
st.done = true;
out.completions.extend(st.completion.take());
if let Some(completion) = st.completion.take() {
out.completions.push(completion);
}
}
return true;
}
Expand All @@ -507,7 +518,9 @@ fn pump_one_frame(
.write(b)
});
st.done = true;
out.completions.extend(st.completion.take());
if let Some(completion) = st.completion.take() {
out.completions.push(completion);
}
return true;
}
false
Expand Down
50 changes: 50 additions & 0 deletions crates/httpjet/src/pipeline/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,56 @@ async fn static_get_serves_litespeed_etag_and_revalidates_to_304() {
assert_eq!(body_bytes(resp.into_body()).as_ref(), b"hello after edit\n");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cgi_route_summary_reloads_and_missing_processor_fails_closed() {
let root = temp_root("cgi-route-summary");
const SOURCE: &[u8] = b"<?php echo 'must never be served';";
std::fs::write(root.join("app.fcgi"), SOURCE).unwrap();

let initial = build_state(root);
assert!(!initial.has_cgi_script_routes);
assert!(initial.fastcgi.is_empty());

// A declared CGI suffix is security-significant even when its named
// processor is absent: the script must resolve as executable and return
// 503, never fall through to static source serving. The summary is rebuilt
// from config on reload rather than inferred from the handler map.
let mut with_route = (*initial.server).clone();
let vhost = with_route
.vhosts
.get_mut(VHOST)
.unwrap()
.config
.as_mut()
.unwrap();
Arc::make_mut(vhost).script_handlers.push(ScriptHandler {
suffix: "fcgi".into(),
kind: ContextKind::Cgi,
handler: "missing-fastcgi".into(),
});
let routed = ServerState::reload(&initial, Arc::new(with_route)).unwrap();
assert!(routed.has_cgi_script_routes);
assert!(routed.fastcgi.is_empty());

let response = run(&routed, get(CANON_HOST, "/app.fcgi", None)).await;
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
assert_ne!(body_bytes(response.into_body()).as_ref(), SOURCE);

let mut without_route = (*routed.server).clone();
let vhost = without_route
.vhosts
.get_mut(VHOST)
.unwrap()
.config
.as_mut()
.unwrap();
Arc::make_mut(vhost)
.script_handlers
.retain(|handler| handler.kind != ContextKind::Cgi);
let restored = ServerState::reload(&routed, Arc::new(without_route)).unwrap();
assert!(!restored.has_cgi_script_routes);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn waf_runs_before_static_cache_and_can_block_a_previously_allowed_path() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
Expand Down
83 changes: 65 additions & 18 deletions crates/httpjet/src/pipeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,12 +1096,54 @@ fn strip_empty_query(uri: &http::Uri) -> Option<http::Uri> {
/// while keeping cache-safety, compression and transport headers under httpjet's
/// final control. Every full and on-core response funnel calls this helper.
async fn apply_response_transforms(state: &ServerState, ctx: &ReqCtx, resp: &mut Response) {
state.extensions.run_response_transforms(ctx, resp).await;
// The distributed binary ships an empty compile-time registry. Avoid
// constructing and polling its no-op async dispatcher on every response.
if !state.extensions.is_empty() {
state.extensions.run_response_transforms(ctx, resp).await;
}
for transform in &state.transforms {
transform.transform(ctx, resp).await;
}
}

/// Default-build entry point: return the pipeline future directly so every
/// request does not pay for a redundant outer async state machine. The OTel
/// build keeps its wrapper below because it may need to establish trace context
/// around that future.
#[cfg(not(feature = "otel"))]
#[allow(clippy::too_many_arguments)]
pub fn handle<'a>(
state: Arc<ServerState>,
listener: &'a str,
peer_ip: IpAddr,
local_addr: std::net::SocketAddr,
peer_port: u16,
is_tls: bool,
peer_unix: bool,
mtls_required: bool,
tls: Option<hj_core::TlsParams>,
proto: Proto,
sni: Option<&'a str>,
req: Request,
) -> impl std::future::Future<Output = Response> + 'a {
handle_inner(
state,
listener,
peer_ip,
local_addr,
peer_port,
is_tls,
peer_unix,
mtls_required,
tls,
proto,
sni,
req,
)
}

#[cfg(feature = "otel")]
#[allow(clippy::too_many_arguments)]
pub async fn handle(
state: Arc<ServerState>,
listener: &str,
Expand All @@ -1116,7 +1158,6 @@ pub async fn handle(
sni: Option<&str>,
req: Request,
) -> Response {
#[cfg(feature = "otel")]
if crate::otel::enabled() {
let mut req = req;
let transport = req
Expand Down Expand Up @@ -1466,22 +1507,26 @@ async fn handle_inner(
ctx.set_env("HTTP_ACCEPT_ENCODING", ae.to_string());
}
set_redirect_guard(&mut ctx, req.uri(), &req_host);
match state.extensions.run_pre_handlers(&ctx, &req).await {
Ok(hj_extension::PreHandlerDecision::Continue) => {
dispatch(&state, host_foreign, req_host, &mut ctx, req).await
}
Ok(hj_extension::PreHandlerDecision::Respond(response)) => response,
Err(error) => {
let status = error.status();
tracing::warn!(
request_id = %ctx.request_id,
vhost = %ctx.vhost_name,
status = status.as_u16(),
error = %error,
"compile-time pre-handler extension failed"
);
error_page(status)
if state.extensions.has_pre_handlers() {
match state.extensions.run_pre_handlers(&ctx, &req).await {
Ok(hj_extension::PreHandlerDecision::Continue) => {
dispatch(&state, host_foreign, req_host, &mut ctx, req).await
}
Ok(hj_extension::PreHandlerDecision::Respond(response)) => response,
Err(error) => {
let status = error.status();
tracing::warn!(
request_id = %ctx.request_id,
vhost = %ctx.vhost_name,
status = status.as_u16(),
error = %error,
"compile-time pre-handler extension failed"
);
error_page(status)
}
}
} else {
dispatch(&state, host_foreign, req_host, &mut ctx, req).await
}
};

Expand Down Expand Up @@ -3018,7 +3063,9 @@ async fn dispatch(
// ---- 7. Suffix routing: LSAPI (php/html) or static -------------------
// Reuse the split resolved once above (B5) — `cur_path` is unchanged since.
if let Some((script_abs, script_name, path_info)) = script_split {
if let Some(handler_name) = fastcgi_handler_for_script(ctx, &script_abs) {
if state.has_cgi_script_routes
&& let Some(handler_name) = fastcgi_handler_for_script(ctx, &script_abs)
{
let Some(handler) = state
.fastcgi_handler(&ctx.vhost_name, handler_name)
.cloned()
Expand Down
32 changes: 18 additions & 14 deletions crates/httpjet/src/pipeline/suffix_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,26 @@ pub(super) fn split_script_path(
// resolution and authorization path. Dispatch later selects FastCGI only
// when the named processor is explicitly type=fcgi; unsupported CGI still
// resolves as executable and fails 503 rather than serving source bytes.
let cgi_suffixes: Vec<String> = ctx
.vhost
.script_handlers
.iter()
.filter(|handler| handler.kind == hj_core::config::ContextKind::Cgi)
.map(|handler| handler.suffix.to_ascii_lowercase())
.collect();
let php_suffixes = if cgi_suffixes
.iter()
.all(|suffix| php_suffixes.contains(suffix))
{
let php_suffixes = if !state.has_cgi_script_routes {
php_suffixes
} else {
let mut combined = php_suffixes.into_owned();
combined.extend(cgi_suffixes);
std::borrow::Cow::Owned(combined)
let cgi_suffixes: Vec<String> = ctx
.vhost
.script_handlers
.iter()
.filter(|handler| handler.kind == hj_core::config::ContextKind::Cgi)
.map(|handler| handler.suffix.to_ascii_lowercase())
.collect();
if cgi_suffixes
.iter()
.all(|suffix| php_suffixes.contains(suffix))
{
php_suffixes
} else {
let mut combined = php_suffixes.into_owned();
combined.extend(cgi_suffixes);
std::borrow::Cow::Owned(combined)
}
};
// Hot-path gate: only chains that actually carry a `SetHandler`/`AddHandler`/
// `AddType` directive pay the per-prefix scope-match cost. Bool-field scan over
Expand Down
5 changes: 3 additions & 2 deletions crates/httpjet/src/serving_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ impl From<Arc<ArcSwap<ServerState>>> for ServingView {
}
}

/// Selected once before dispatch and carried through a fast-path miss into the
/// Tokio bridge. Remote request headers cannot manufacture this extension.
/// Selected once before dispatch and carried through a fast-path miss beside
/// the request in the transport bridge context. Remote request data cannot
/// manufacture or replace this snapshot.
#[derive(Clone)]
pub(crate) struct RequestGeneration(pub(crate) Arc<ServerState>);
21 changes: 21 additions & 0 deletions crates/httpjet/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ pub struct ServerState {
/// Opt-in FastCGI handlers keyed by `(vhost scope, processor name)`.
/// A scoped processor always wins over a global processor of the same name.
pub fastcgi: HashMap<(Option<String>, String), Arc<FastCgi>>,
/// True when any loaded vhost declares an explicit CGI/FastCGI suffix route.
/// Kept independently of `fastcgi`: a declared route whose processor is
/// missing must still resolve as executable and fail closed with 503.
pub(crate) has_cgi_script_routes: bool,
/// Reverse-proxy engine for this config generation. Reload retains unchanged
/// upstream Arcs while obsolete named definitions drain with the old state.
pub proxy: Arc<Proxy>,
Expand Down Expand Up @@ -494,6 +498,7 @@ struct ConfigDerived {
inline_rules: HashMap<String, Arc<RuleSet>>,
ext_by_name: HashMap<String, ExtProcessor>,
php_suffixes: HashSet<String>,
has_cgi_script_routes: bool,
acl: Arc<AccessControl>,
client_throttle: hj_acl::ClientThrottle,
compress: Arc<Compress>,
Expand All @@ -515,6 +520,19 @@ fn build_config_derived(
.as_ref()
.map(|p| p.suffixes.iter().map(|s| s.to_ascii_lowercase()).collect())
.unwrap_or_default();
// This summarizes DECLARED suffix routes, not successfully constructed
// FastCGI processors. Keeping those concepts separate preserves the
// source-disclosure guard: a route that names a missing processor must
// still enter script dispatch and return 503. The common configuration has
// no CGI routes, so request routing can skip both per-vhost suffix scans.
let has_cgi_script_routes = server.vhosts.values().any(|declaration| {
declaration.config.as_ref().is_some_and(|vhost| {
vhost
.script_handlers
.iter()
.any(|handler| handler.kind == hj_core::config::ContextKind::Cgi)
})
});

// Pre-parse each vhost's inline rewrite rules once.
let mut inline_rules = HashMap::new();
Expand Down Expand Up @@ -580,6 +598,7 @@ fn build_config_derived(
inline_rules,
ext_by_name,
php_suffixes,
has_cgi_script_routes,
acl,
client_throttle,
compress,
Expand Down Expand Up @@ -946,6 +965,7 @@ impl ServerState {
static_handler: cd.static_handler,
lsapi,
fastcgi,
has_cgi_script_routes: cd.has_cgi_script_routes,
proxy,
rewrite_cache: Arc::new(HtaccessCache::new()),
inline_rules: cd.inline_rules,
Expand Down Expand Up @@ -1092,6 +1112,7 @@ impl ServerState {
// ---- runtime half: carried forward (proxy filtered to new config) ----
lsapi: old.lsapi.clone(),
fastcgi,
has_cgi_script_routes: cd.has_cgi_script_routes,
proxy,
// Candidate construction must not clear live caches. Separate generations
// also prevent an in-flight old request repopulating the new rule memo.
Expand Down
Loading
Loading