mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
[pitboss] phase 08: Track J.6 + Track L.6 — HEADER_INJECTION corpus + every HTTP framework
This commit is contained in:
parent
59d627cb22
commit
e0e49f65d3
45 changed files with 2552 additions and 41 deletions
110
src/dynamic/framework/adapters/header_go.rs
Normal file
110
src/dynamic/framework/adapters/header_go.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! Go [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`http.ResponseWriter.Header().Set` / `Add`, Gin `c.Header`,
|
||||
//! Echo `c.Response().Header().Set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Go HTTP response writers and the surrounding
|
||||
//! source imports `net/http` or one of the supported frameworks.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderGoAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-go";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "Set" | "Add" | "Header" | "WriteHeader")
|
||||
}
|
||||
|
||||
fn source_imports_go_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"\"net/http\"",
|
||||
b"net/http\"",
|
||||
b"github.com/gin-gonic/gin",
|
||||
b"github.com/labstack/echo",
|
||||
b"github.com/gofiber/fiber",
|
||||
b"github.com/go-chi/chi",
|
||||
b".Header().Set",
|
||||
b".Header().Add",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderGoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Go
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_go_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_header_set() {
|
||||
let src: &[u8] =
|
||||
b"package x\nimport \"net/http\"\nfunc Run(w http.ResponseWriter, v string) { w.Header().Set(\"Set-Cookie\", v) }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Set")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"package x\nfunc Add(a, b int) int { return a + b }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
106
src/dynamic/framework/adapters/header_java.rs
Normal file
106
src/dynamic/framework/adapters/header_java.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Java [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`HttpServletResponse.setHeader` / `addHeader`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical servlet response-writer entry points and the
|
||||
//! surrounding source imports a servlet API.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderJavaAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-java";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "setHeader" | "addHeader" | "setDateHeader" | "addDateHeader" | "setIntHeader" | "addIntHeader")
|
||||
}
|
||||
|
||||
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"javax.servlet",
|
||||
b"jakarta.servlet",
|
||||
b"HttpServletResponse",
|
||||
b"ServerHttpResponse",
|
||||
b"org.springframework.http",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderJavaAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Java
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_servlet(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_setheader() {
|
||||
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
|
||||
class C { void run(HttpServletResponse r, String v) { r.setHeader(\"Set-Cookie\", v); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"class C { int add(int a, int b) { return a + b; } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
118
src/dynamic/framework/adapters/header_js.rs
Normal file
118
src/dynamic/framework/adapters/header_js.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! JavaScript [`super::super::FrameworkAdapter`] matching HTTP
|
||||
//! response-header CRLF-injection sink constructions
|
||||
//! (`http.ServerResponse#setHeader`, Express `res.setHeader` /
|
||||
//! `res.header`, Koa `ctx.set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Node response writers and the surrounding source
|
||||
//! imports the matching framework module or `node:http`.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderJsAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-js";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "setHeader" | "header" | "set" | "writeHead" | "append")
|
||||
}
|
||||
|
||||
fn source_uses_node_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require('http')",
|
||||
b"require(\"http\")",
|
||||
b"require('node:http')",
|
||||
b"from 'http'",
|
||||
b"from \"http\"",
|
||||
b"require('express')",
|
||||
b"require(\"express\")",
|
||||
b"from 'express'",
|
||||
b"from \"express\"",
|
||||
b"require('koa')",
|
||||
b"require(\"koa\")",
|
||||
b"require('fastify')",
|
||||
b"require(\"fastify\")",
|
||||
b"res.setHeader",
|
||||
b"res.header",
|
||||
b"ctx.set(",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderJsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_node_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_setheader() {
|
||||
let src: &[u8] = b"const http = require('http');\n\
|
||||
function run(res, value) { res.setHeader('Set-Cookie', value); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
109
src/dynamic/framework/adapters/header_php.rs
Normal file
109
src/dynamic/framework/adapters/header_php.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! PHP [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions (`header()`,
|
||||
//! Symfony / Laravel `Response::headers->set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical PHP response writers and the surrounding source
|
||||
//! either references the built-in `$_SERVER` request surface or
|
||||
//! imports a Symfony / Laravel response helper.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderPhpAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-php";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
let last = last.rsplit_once("->").map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "header" | "setRawHeader" | "headers" | "set" | "add")
|
||||
}
|
||||
|
||||
fn source_uses_php_response(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"header(",
|
||||
b"$_SERVER",
|
||||
b"Symfony\\Component\\HttpFoundation",
|
||||
b"Illuminate\\Http\\Response",
|
||||
b"->headers->",
|
||||
b"response()->header",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderPhpAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_php_response(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_header_call() {
|
||||
let src: &[u8] = b"<?php\nfunction run($v) { header('Set-Cookie: ' . $v); }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("header")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
112
src/dynamic/framework/adapters/header_python.rs
Normal file
112
src/dynamic/framework/adapters/header_python.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Python [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`flask.Response.headers.__setitem__`, Django `HttpResponse.__setitem__`,
|
||||
//! Starlette `headers.append`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Python web framework response writers and the
|
||||
//! surrounding source imports the matching framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderPythonAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-python";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"__setitem__" | "set_header" | "setdefault" | "add_header" | "append"
|
||||
) || matches!(name, "Response.headers.__setitem__" | "make_response" | "Response.headers.add")
|
||||
}
|
||||
|
||||
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"from flask",
|
||||
b"import flask",
|
||||
b"from django.http",
|
||||
b"from starlette",
|
||||
b"from fastapi",
|
||||
b"response.headers",
|
||||
b"resp.headers",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderPythonAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Python
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_python_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_flask_header_assignment() {
|
||||
let src: &[u8] = b"from flask import make_response\n\
|
||||
def run(value):\n resp = make_response('hi')\n resp.headers['Set-Cookie'] = value\n return resp\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("__setitem__")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b):\n return a + b\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`Rack::Response#set_header`, Rails `response.headers[]=`,
|
||||
//! Sinatra `response['Set-Cookie']=`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Ruby web framework response writers and the
|
||||
//! surrounding source imports / mentions Rack / Rails / Sinatra.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderRubyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-ruby";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('#').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "set_header" | "[]=" | "store" | "add_header")
|
||||
}
|
||||
|
||||
fn source_uses_ruby_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Rack::Response",
|
||||
b"require 'rack'",
|
||||
b"require \"rack\"",
|
||||
b"require 'sinatra'",
|
||||
b"require \"sinatra\"",
|
||||
b"ActionController",
|
||||
b"response.headers",
|
||||
b"response[",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderRubyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Ruby
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_ruby_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_set_header() {
|
||||
let src: &[u8] = b"require 'rack'\n\
|
||||
def run(value)\n response = Rack::Response.new\n response.set_header('Set-Cookie', value)\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("set_header")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Rust [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`axum`-style `headers_mut().insert`, `actix-web` `HttpResponse::
|
||||
//! insert_header`, `hyper` `Response::headers_mut().insert`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Rust HTTP response header writers and the
|
||||
//! surrounding source imports `http`, `axum`, `actix_web`, or
|
||||
//! `hyper`.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderRustAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-rust";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "insert" | "append" | "insert_header" | "header")
|
||||
}
|
||||
|
||||
fn source_imports_rust_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"use http::HeaderMap",
|
||||
b"use http::header",
|
||||
b"use axum::",
|
||||
b"use actix_web",
|
||||
b"use hyper::",
|
||||
b"HeaderMap::new",
|
||||
b"HeaderValue::from",
|
||||
b"headers_mut()",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderRustAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Rust
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_rust_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_headers_insert() {
|
||||
let src: &[u8] = b"use axum::http::HeaderMap;\n\
|
||||
fn run(headers: &mut HeaderMap, value: &str) { headers.insert(\"set-cookie\", value.parse().unwrap()); }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("insert")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"fn add(a: i32, b: i32) -> i32 { a + b }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,13 @@
|
|||
//! the route / framework adapters; the per-cap sink adapters live
|
||||
//! here so the per-language verticals can ship independently.
|
||||
|
||||
pub mod header_go;
|
||||
pub mod header_java;
|
||||
pub mod header_js;
|
||||
pub mod header_php;
|
||||
pub mod header_python;
|
||||
pub mod header_ruby;
|
||||
pub mod header_rust;
|
||||
pub mod java_deserialize;
|
||||
pub mod java_thymeleaf;
|
||||
pub mod js_handlebars;
|
||||
|
|
@ -33,6 +40,13 @@ pub mod xxe_php;
|
|||
pub mod xxe_python;
|
||||
pub mod xxe_ruby;
|
||||
|
||||
pub use header_go::HeaderGoAdapter;
|
||||
pub use header_java::HeaderJavaAdapter;
|
||||
pub use header_js::HeaderJsAdapter;
|
||||
pub use header_php::HeaderPhpAdapter;
|
||||
pub use header_python::HeaderPythonAdapter;
|
||||
pub use header_ruby::HeaderRubyAdapter;
|
||||
pub use header_rust::HeaderRustAdapter;
|
||||
pub use java_deserialize::JavaDeserializeAdapter;
|
||||
pub use java_thymeleaf::JavaThymeleafAdapter;
|
||||
pub use js_handlebars::JsHandlebarsAdapter;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue