From 75db1fe09b26cf6a506d40aaf347741410ac0a1c Mon Sep 17 00:00:00 2001 From: Philippe Antoine Date: Tue, 7 Jul 2026 09:00:05 +0200 Subject: [PATCH 1/2] http2: normalize uri Ticket: 8130 Like HTTP1 does as per RFC 1886 --- rust/src/detect/transforms/urldecode.rs | 2 +- rust/src/http2/detect.rs | 23 ++++++++ rust/src/http2/http2.rs | 74 ++++++++++++++++++++++++- src/app-layer-http2.c | 4 ++ src/detect-http-uri.c | 27 ++++++++- 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/rust/src/detect/transforms/urldecode.rs b/rust/src/detect/transforms/urldecode.rs index e693080d72..a6063d2719 100644 --- a/rust/src/detect/transforms/urldecode.rs +++ b/rust/src/detect/transforms/urldecode.rs @@ -33,7 +33,7 @@ unsafe extern "C" fn url_decode_setup( return SCDetectSignatureAddTransform(s, G_TRANSFORM_URL_DECODE_ID, ptr::null_mut()); } -fn hex_value(i: u8) -> Option { +pub(crate) fn hex_value(i: u8) -> Option { match i { 0x30..=0x39 => Some(i - 0x30), 0x41..=0x46 => Some(i - 0x41 + 10), diff --git a/rust/src/http2/detect.rs b/rust/src/http2/detect.rs index 09a036563c..5406f0afcd 100644 --- a/rust/src/http2/detect.rs +++ b/rust/src/http2/detect.rs @@ -532,6 +532,20 @@ pub unsafe extern "C" fn SCHttp2TxGetResponseLine( #[no_mangle] pub unsafe extern "C" fn SCHttp2TxGetUri( tx: &mut HTTP2Transaction, buffer: *mut *const u8, buffer_len: *mut u32, +) -> u8 { + if !tx.normalized_uri.is_empty() + || http2_frames_get_header_firstvalue(tx, Direction::ToServer, ":path").is_ok() + { + *buffer = tx.normalized_uri.as_ptr(); //unsafe + *buffer_len = tx.normalized_uri.len() as u32; + return 1; + } + return 0; +} + +#[no_mangle] +pub unsafe extern "C" fn SCHttp2TxGetRawUri( + tx: &mut HTTP2Transaction, buffer: *mut *const u8, buffer_len: *mut u32, ) -> u8 { if let Ok(value) = http2_frames_get_header_firstvalue(tx, Direction::ToServer, ":path") { *buffer = value.as_ptr(); //unsafe @@ -1003,6 +1017,15 @@ pub unsafe extern "C" fn SCHttp2TxSetUri( http2_tx_set_header(state, ":path".as_bytes(), slice) } +#[no_mangle] +pub unsafe extern "C" fn SCHttp2TxSetNormUri( + state: &mut HTTP2State, buffer: *const u8, buffer_len: u32, +) { + let slice = build_slice!(buffer, buffer_len as usize); + let tx = state.transactions.back_mut().unwrap(); + tx.normalized_uri = slice.to_vec(); +} + fn http2_tx_set_settings(state: &mut HTTP2State, input: &[u8]) { match STANDARD.decode(input) { Ok(dec) => { diff --git a/rust/src/http2/http2.rs b/rust/src/http2/http2.rs index 9322036f35..27d258f0c3 100644 --- a/rust/src/http2/http2.rs +++ b/rust/src/http2/http2.rs @@ -24,6 +24,7 @@ use super::range::{SCHTPFileCloseHandleRange, SCHttpRangeFreeBlock}; use crate::applayer::{self, *}; use crate::conf::{conf_get, get_memval}; use crate::core::*; +use crate::detect::transforms::urldecode::hex_value; use crate::direction::Direction; use crate::dns::dns::DnsVariant; use crate::filecontainer::FileContainerWrapper; @@ -289,6 +290,7 @@ pub struct HTTP2Transaction { pub progress: HTTP2Progress, to_drop: bool, child_stream_id: u32, + pub normalized_uri: Vec, pub frames_tc: Vec, pub frames_ts: Vec, @@ -306,6 +308,70 @@ pub struct HTTP2Transaction { pub doh: Option, } +enum NormalizeUriState { + Path, + Percent, + Percent2, +} + +fn http2_normalize_uri(uri: &[u8]) -> Vec { + let mut state = NormalizeUriState::Path; + let mut query = false; + let mut percent = 0u8; + let mut prevc = 0u8; + let mut r = Vec::with_capacity(uri.len()); + for c in uri { + match state { + NormalizeUriState::Path => match c { + b'%' => state = NormalizeUriState::Percent, + b'+' if query => { + r.push(b' '); + } + b'?' => { + query = true; + r.push(*c); + } + _ => { + r.push(*c); + } + }, + NormalizeUriState::Percent => { + if let Some(v) = hex_value(*c) { + percent = v << 4; + prevc = *c; + state = NormalizeUriState::Percent2; + } else { + r.push(b'%'); + r.push(*c); + state = NormalizeUriState::Path; + } + } + NormalizeUriState::Percent2 => { + if let Some(v) = hex_value(*c) { + percent |= v; + r.push(percent); + } else { + r.push(b'%'); + r.push(prevc); + r.push(*c); + } + state = NormalizeUriState::Path; + } + } + } + match state { + NormalizeUriState::Percent => { + r.push(b'%'); + } + NormalizeUriState::Percent2 => { + r.push(b'%'); + r.push(prevc); + } + _ => {} + } + return r; +} + impl Transaction for HTTP2Transaction { fn id(&self) -> u64 { self.tx_id @@ -325,6 +391,7 @@ impl HTTP2Transaction { stream_id: 0, child_stream_id: 0, progress: HTTP2Progress::STREAM(HTTP2StreamProgress::init()), + normalized_uri: Vec::new(), to_drop: false, frames_tc: Vec::new(), frames_ts: Vec::new(), @@ -389,7 +456,10 @@ impl HTTP2Transaction { self.doh = Some(doh); } } - } else if block.name.as_ref() == b":path" { + } else if block.name.as_ref() == b":path" && dir == Direction::ToServer { + if self.normalized_uri.is_empty() { + self.normalized_uri = http2_normalize_uri(&block.value); + } path = Some(&block.value); } else if block.name.eq_ignore_ascii_case(b":authority") { authority = Some(&block.value); @@ -725,7 +795,7 @@ pub struct HTTP2State { response_frame_size: u32, dynamic_headers_ts: HTTP2DynTable, dynamic_headers_tc: HTTP2DynTable, - transactions: VecDeque, + pub transactions: VecDeque, progress: HTTP2ConnectionState, comp_len: u64, diff --git a/src/app-layer-http2.c b/src/app-layer-http2.c index fc299cdaec..2ac96365ab 100644 --- a/src/app-layer-http2.c +++ b/src/app-layer-http2.c @@ -88,6 +88,10 @@ void SCHTTP2MimicHttp1Request(void *alstate_orig, void *h2s) SCHttp2TxSetUri(h2s, bstr_ptr(htp_tx_request_uri(h1tx)), (uint32_t)bstr_len(htp_tx_request_uri(h1tx))); } + if (htp_tx_normalized_uri(h1tx) != NULL) { + SCHttp2TxSetNormUri(h2s, bstr_ptr(htp_tx_normalized_uri(h1tx)), + (uint32_t)bstr_len(htp_tx_normalized_uri(h1tx))); + } size_t nbheaders = htp_tx_request_headers_size(h1tx); for (size_t i = 0; i < nbheaders; i++) { const htp_header_t *h = htp_tx_request_header_index(h1tx, i); diff --git a/src/detect-http-uri.c b/src/detect-http-uri.c index d3b6b28f82..89fb8ae8e2 100644 --- a/src/detect-http-uri.c +++ b/src/detect-http-uri.c @@ -82,6 +82,28 @@ static int DetectHttpRawUriSetupSticky(DetectEngineCtx *de_ctx, Signature *s, co static int g_http_raw_uri_buffer_id = 0; static int g_http_uri_buffer_id = 0; +static InspectionBuffer *GetRawData2(DetectEngineThreadCtx *det_ctx, + const DetectEngineTransforms *transforms, Flow *_f, const uint8_t _flow_flags, void *txv, + const int list_id) +{ + SCEnter(); + + InspectionBuffer *buffer = SCInspectionBufferGet(det_ctx, list_id); + if (!buffer->initialized) { + uint32_t b_len = 0; + const uint8_t *b = NULL; + + if (SCHttp2TxGetRawUri(txv, &b, &b_len) != 1) + return NULL; + if (b == NULL || b_len == 0) + return NULL; + + SCInspectionBufferSetupAndApplyTransforms(det_ctx, list_id, buffer, b, b_len, transforms); + } + + return buffer; +} + /** * \brief Registration function for keywords: http_uri and http.uri */ @@ -150,12 +172,11 @@ void DetectHttpUriRegister (void) DetectAppLayerMpmRegister("http_raw_uri", SIG_FLAG_TOSERVER, 2, PrefilterGenericMpmRegister, GetRawData, ALPROTO_HTTP1, HTP_REQUEST_PROGRESS_LINE); - // no difference between raw and decoded uri for HTTP2 DetectAppLayerInspectEngineRegisterSubState("http_raw_uri", ALPROTO_HTTP2, SIG_FLAG_TOSERVER, - HTTP2TxTypeStream, HTTP2ProgHeaders, DetectEngineInspectBufferGeneric, GetData2); + HTTP2TxTypeStream, HTTP2ProgHeaders, DetectEngineInspectBufferGeneric, GetRawData2); DetectAppLayerMpmRegisterSubState("http_raw_uri", SIG_FLAG_TOSERVER, 2, - PrefilterGenericMpmRegister, GetData2, ALPROTO_HTTP2, HTTP2TxTypeStream, + PrefilterGenericMpmRegister, GetRawData2, ALPROTO_HTTP2, HTTP2TxTypeStream, HTTP2ProgHeaders); DetectBufferTypeSetDescriptionByName("http_raw_uri", From 49bbfcd56edcd624af14c008c6f2bdc17c46daf7 Mon Sep 17 00:00:00 2001 From: Philippe Antoine Date: Tue, 7 Jul 2026 09:12:23 +0200 Subject: [PATCH 2/2] http1: do convert + to space in query by default Ticket: 8130 --- doc/userguide/rules/differences-from-snort.rst | 12 ------------ src/app-layer-htp.c | 3 +-- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/doc/userguide/rules/differences-from-snort.rst b/doc/userguide/rules/differences-from-snort.rst index b79b3c0e03..47dc13c7c9 100644 --- a/doc/userguide/rules/differences-from-snort.rst +++ b/doc/userguide/rules/differences-from-snort.rst @@ -100,18 +100,6 @@ Automatic Protocol Detection - Use ``,norm`` for normalized buffer - e.g. ``urilen:>20,norm;`` -``http_uri`` Buffer -------------------- - -- In Snort, the ``http_uri`` buffer normalizes '+' characters - (0x2B) to spaces (0x20). - - - Suricata can do this as well but you have to explicitly - set ``query-plusspace-decode: yes`` in the ``libhtp`` section of Suricata's yaml file. - -- `https://redmine.openinfosecfoundation.org/issues/1035 `_ -- `https://github.com/inliniac/suricata/pull/620 `_ - ``http_header`` Buffer ---------------------- diff --git a/src/app-layer-htp.c b/src/app-layer-htp.c index c13a80bb60..5b3897e64b 100644 --- a/src/app-layer-htp.c +++ b/src/app-layer-htp.c @@ -1946,8 +1946,6 @@ static void HTPConfigSetDefaultsPhase1(HTPCfgRec *cfg_prec) htp_config_set_parse_request_cookies(cfg_prec->cfg, 0); htp_config_set_allow_space_uri(cfg_prec->cfg, 1); - /* don't convert + to space by default */ - htp_config_set_plusspace_decode(cfg_prec->cfg, 0); // enables request decompression htp_config_set_request_decompression(cfg_prec->cfg, 1); htp_config_set_lzma_layers(cfg_prec->cfg, HTP_CONFIG_DEFAULT_LZMA_LAYERS); @@ -4472,6 +4470,7 @@ libhtp:\n\ personality: IDS\n\ double-decode-path: yes\n\ double-decode-query: yes\n\ + query-plusspace-decode: no\n\ "; SCConfCreateContextBackup();