coraza 0.1.0

Safe Rust bindings to OWASP Coraza WAF
/*
 * Copyright 2022 OWASP Coraza contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

use coraza::{Error, WafConfig};

#[test]
fn test_full_waf_lifecycle() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();
    tx.add_request_header("Host", "localhost");
    tx.process_request_headers().unwrap();
    tx.process_response_headers(200, "HTTP/1.1").unwrap();
    tx.process_response_body().unwrap();
    tx.process_logging();

    assert!(tx.intervention().is_none());
    tx.close().unwrap();
}

#[test]
fn test_waf_blocks_bad_request() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives(r#"SecRule REMOTE_ADDR "127.0.0.1" "id:1,phase:1,deny,log,status:403""#)
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();

    // process_request_headers returns Err(Intervention) when a rule fires
    let result = tx.process_request_headers();
    assert!(result.is_err());

    tx.process_logging();

    // The intervention should be available
    let intervention = tx.intervention().unwrap();
    assert_eq!(intervention.status, 403);
    assert!(intervention.is_disruptive());
}

#[test]
fn test_waf_redirect() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives(
            r#"SecRule ARGS:trigger "@streq yes" "id:10,phase:1,status:302,redirect:http://example.com""#,
        )
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("10.0.0.1", 12345, "localhost", 80)
        .unwrap();
    tx.add_get_argument("trigger", "yes");
    tx.process_uri("/?trigger=yes", "GET", "HTTP/1.1").unwrap();

    // process_request_headers returns Err(Intervention) when a rule fires
    let result = tx.process_request_headers();
    assert!(result.is_err());

    tx.process_logging();

    // The intervention should be available
    let intervention = tx.intervention().unwrap();
    assert_eq!(intervention.status, 302);
    assert!(intervention.is_redirect());
    assert_eq!(intervention.data.as_deref(), Some("http://example.com"));
}

#[test]
fn test_multiple_transactions() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    for i in 0..5 {
        let id = format!("tx-{}", i);
        let mut tx = waf.new_transaction_with_id(&id);

        tx.process_connection("127.0.0.1", 8080, "localhost", 80)
            .unwrap();
        tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();
        tx.process_request_headers().unwrap();
        tx.process_logging();

        assert!(tx.intervention().is_none());
    }
}

#[test]
fn test_waf_rules_count() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .with_directives(r#"SecRule REMOTE_ADDR "127.0.0.1" "id:1,phase:1,pass""#)
        .with_directives(r#"SecRule REMOTE_ADDR "10.0.0.1" "id:2,phase:1,pass""#)
        .build()
        .unwrap();

    assert!(waf.rules_count() >= 2);
}

#[test]
fn test_waf_build_error() {
    let result = WafConfig::new()
        .unwrap()
        .with_directives_from_file("/nonexistent/path/rules.conf")
        .build();

    assert!(result.is_err());
    match result.unwrap_err() {
        Error::WafCreation(_) => {} // expected
        other => panic!("expected WafCreation error, got: {:?}", other),
    }
}

#[test]
fn test_debug_log_callback() {
    let log_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let log_count_clone = log_count.clone();

    let _waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .with_debug_log_callback(move |_level, _msg, _fields| {
            log_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        })
        .build()
        .unwrap();

    // Debug messages depend on Coraza's internal log level (default: ERROR).
    // We verify the callback was accepted without asserting message count.
}

#[test]
fn test_error_callback() {
    let matched = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
    let matched_clone = matched.clone();

    let waf = WafConfig::new()
        .unwrap()
        .with_directives(
            r#"SecRule REMOTE_ADDR "127.0.0.1" "id:1,phase:1,deny,log,msg:'test block',status:403""#,
        )
        .with_error_callback(move |rule| {
            matched_clone
                .lock()
                .unwrap()
                .push((rule.rule_id, rule.message.clone()));
        })
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();

    // process_request_headers returns Err(Intervention) when a rule fires
    let _ = tx.process_request_headers();

    tx.process_logging();

    let rules = matched.lock().unwrap();
    assert!(!rules.is_empty(), "expected at least one matched rule");
    assert_eq!(rules[0].0, 1); // rule ID
}

#[test]
fn test_transaction_with_id() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    let tx = waf.new_transaction_with_id("my-custom-id");
    assert!(!tx.is_closed());
}

#[test]
fn test_intervention_none_when_no_match() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();
    tx.process_request_headers().unwrap();

    assert!(tx.intervention().is_none());
}

#[test]
fn test_request_body_processing() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .with_directives("SecRequestBodyAccess On")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/submit", "POST", "HTTP/1.1").unwrap();
    tx.add_request_header("Content-Type", "application/x-www-form-urlencoded");
    tx.process_request_headers().unwrap();

    tx.append_request_body(b"hello=world&foo=bar").unwrap();
    tx.process_request_body().unwrap();

    tx.process_response_headers(200, "HTTP/1.1").unwrap();
    tx.process_response_body().unwrap();
    tx.process_logging();

    assert!(tx.intervention().is_none());
}

#[test]
fn test_response_body_processing() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .with_directives("SecResponseBodyAccess On")
        .with_directives("SecResponseBodyMimeType text/html text/plain")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/page", "GET", "HTTP/1.1").unwrap();
    tx.process_request_headers().unwrap();

    // Process response headers first to set the status code
    tx.process_response_headers(200, "HTTP/1.1").unwrap();

    // Now check if response body is processable
    // Note: is_response_body_processable depends on the response MIME type
    // which is set via Content-Type header. We need to add it before processing.
    // For this test, we just verify the method doesn't panic.
    let _processable = tx.is_response_body_processable();

    tx.append_response_body(b"<html><body>Hello</body></html>")
        .unwrap();
    tx.process_response_body().unwrap();
    tx.process_logging();

    assert!(tx.intervention().is_none());
}

#[test]
fn test_add_request_headers_batch() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();

    let headers = vec![
        ("Host", "localhost"),
        ("User-Agent", "test-agent"),
        ("Accept", "text/html"),
    ];
    tx.add_request_headers(&headers);
    tx.process_request_headers().unwrap();
    tx.process_logging();

    assert!(tx.intervention().is_none());
}

#[test]
fn test_add_response_headers_batch() {
    let waf = WafConfig::new()
        .unwrap()
        .with_directives("SecRuleEngine DetectionOnly")
        .build()
        .unwrap();

    let mut tx = waf.new_transaction();

    tx.process_connection("127.0.0.1", 8080, "localhost", 80)
        .unwrap();
    tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();
    tx.process_request_headers().unwrap();

    let headers = vec![("Content-Type", "text/html"), ("X-Custom", "value")];
    tx.process_response_headers(200, "HTTP/1.1").unwrap();
    tx.add_response_headers(&headers);
    tx.process_response_body().unwrap();
    tx.process_logging();

    assert!(tx.intervention().is_none());
}