NAME
App::WebPerimeterScan::HTTP - minimal HTTP/1.1 client for perimeter scanning
VERSION
Version 0.001.0
SYNOPSIS
Fetch a page and check for an error first:
use App::WebPerimeterScan::HTTP;
my $http = App::WebPerimeterScan::HTTP->new;
my $res = $http->fetch('https://example.com/');
if($res->{error}) {
print "failed: $res->{error}\n";
} else {
print "$res->{status} $res->{reason}\n";
print 'Server: ', $http->header($res, 'Server') // '(none)', "\n";
}
Read every Set-Cookie header separately:
for my $cookie (@{ $res->{set_cookie} || [] }) {
print "cookie: $cookie\n";
}
Send an OPTIONS request with an extra header:
my $options = $http->fetch('https://example.com/',
method => 'OPTIONS',
headers => [ 'Origin: https://evil.example' ],
);
print $http->header($options, 'Allow') // '', "\n";
Follow a redirect yourself, only when the target is in scope:
my $first = $http->fetch('http://example.com/');
my $next = $http->header($first, 'Location');
if(defined $next && $scope->allows(URI->new($next)->host)) {
my $second = $http->fetch($next);
}
DESCRIPTION
This is a small HTTP client made for the scanner. It sends one request and returns the response as a hash reference. It is written for this module, instead of using LWP or HTTP::Tiny, for two reasons:
The scanner must see every
Set-Cookieheader separately. Those modules join repeated headers with commas, and a cookie'sExpiresdate also contains a comma, so the join cannot be undone.The scanner must see redirects, not follow them. The caller then decides if the next host is in scope.
The client waits between two requests to the same host (the delay), so that a scan does not overload the server.
The client does not check TLS certificates. This is on purpose: a site with an expired or wrong certificate can still be scanned. The certificate is checked, and reported, by App::WebPerimeterScan::TLS. Do not use this client to send anything secret.
Network problems are returned, not thrown: when a request fails, the result has an error field and no status. Only a programming mistake (an unknown argument, or a method that is not made only of upper-case letters A to Z) is fatal.
REQUIREMENTS
IO::Socket::INET 1.31 and IO::Select 1.17. HTTPS requests also need IO::Socket::SSL 2.000, whose installation needs the OpenSSL headers.
SUBROUTINES/METHODS
new
Purpose: create a client.
Args: named timeout (seconds per request), delay (seconds between requests to one host), max_size (bytes of body kept), agent (User-Agent). Defaults come from %App::WebPerimeterScan::HTTP::config.
Returns: the new object.
Side Effects: none; no connection is made until fetch.
Usage: my $http = App::WebPerimeterScan::HTTP->new(timeout => 5);
EXAMPLE
my $http = App::WebPerimeterScan::HTTP->new(delay => 0); # no throttle, for tests
API SPECIFICATION
INPUT
{
timeout => { type => 'number', min => 0, optional => 1 },
delay => { type => 'number', min => 0, optional => 1 },
max_size => { type => 'integer', min => 1, optional => 1 },
agent => { type => 'string', min => 1, optional => 1 },
}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::HTTP' }
MESSAGES
Message Meaning / resolution
------------------------------- -----------------------------------
Parameter 'X' ... (fatal) A value is negative or not a number.
Unknown parameter 'X' (fatal) A misspelt argument name.
requests
Purpose: the number of requests actually sent (connections that opened).
Args: none.
Returns: a non-negative integer.
Side Effects: none.
Usage: printf "Requests: %d\n", $http->requests;
EXAMPLE
$http->fetch($url);
print $http->requests, "\n";
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'integer', min => 0 }
MESSAGES
None.
fetch
Purpose: send one request and read the response.
Args: $url, then named method (an upper-case token, default GET) and headers (an array reference of Name: value strings; any containing CR or LF are dropped so they cannot inject headers).
Returns: a hash reference. On failure it has url and error (a translated message), plus host and port when the URL was usable. On success: url, host, port, scheme, status, reason, http_version, headers (lower-case name => array of values, in order), set_cookie, body, truncated, content_type, raw_headers and elapsed.
Side Effects: network traffic; may sleep to honour delay; increments requests when a connection opens.
Usage: my $res = $http->fetch($url, method => 'OPTIONS');
EXAMPLE
my $res = $http->fetch('https://example.com/',
headers => [ 'Origin: https://ws-probe.invalid' ]);
if($res->{error}) {
print "failed: $res->{error}\n";
} else {
print "$res->{status} ", scalar @{ $res->{set_cookie} }, " cookie(s)\n";
}
API SPECIFICATION
INPUT
{
url => { type => 'string', min => 1, position => 0 },
method => { type => 'string', matches => qr/\A[A-Z]+\z/, optional => 1 },
headers => { type => 'arrayref', optional => 1 },
}
OUTPUT
{ type => 'hashref' } # see Returns; 'error' present on failure
MESSAGES
These are returned in error, not thrown:
Message Meaning / resolution
---------------------------- ---------------------------------------
invalid URL The URL did not parse.
unsupported scheme 'S' Only http and https are fetched.
URL has no host The URL has no authority part.
connect failed: E TCP or TLS connect failed; E says why.
request write failed The connection closed or timed out
while sending.
no response headers Nothing usable arrived before timeout.
malformed status line The reply was not HTTP/1.x.
PSEUDOCODE
parse the URL; return an error for anything but http(s) with a host
wait until the per-host delay has passed
connect (TLS without verification for https); return an error on failure
write the request line, Host, User-Agent, fixed and extra headers
read until the blank line; parse the status line and headers
read the body by Content-Length, chunked encoding or to EOF
return the parsed response
header
Purpose: the last value of a response header, without dealing in array references.
Args: $response (from fetch) and $name (any case).
Returns: the value, or undef if the header is absent.
Side Effects: none.
Usage: my $server = $http->header($res, 'Server');
EXAMPLE
my $type = $http->header($res, 'Content-Type') // 'none';
API SPECIFICATION
INPUT
{
response => { type => 'hashref', position => 0 },
name => { type => 'string', min => 1, position => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
None.
COMMON PITFALLS
Check
errorbeforestatus. A failed request (no connection, a timeout, a reply that is not HTTP) returns a hash witherrorand nostatus. It does not die.Redirects are not followed. A
301or302is returned as it is. Read theLocationheader and decide yourself.The body may be cut short. Only the first
max_sizebytes (2,000,000 by default) are kept.truncatedis true when the rest was thrown away.headerreturns only the last value. When a header appears more than once,header($res, $name)returns the last one. The full list is in$res->{headers}{$name}, where$namemust be lower case.headeritself ignores case.headerreturnsundefwhen the header is missing, and also for a failed request. Write$http->header($res, 'Server') // ''when you need a string.The delay belongs to one object. Two
HTTPobjects do not wait for each other. Use one object for the whole scan.A delay of 0 turns the wait off. Use it in tests only. Do not use it against a real site.
requestscounts connections that opened. A request that failed before it connected is not counted.The certificate is not checked. See "DESCRIPTION".
ENCODING
URLs must be ASCII. Percent-encode non-ASCII characters in the path and query (
%C3%A9foréin UTF-8), and write an international host name in its Punycode form. A URL that contains raw UTF-8 bytes is encoded a second time and asks for the wrong path.The body is bytes.
bodyis exactly what the server sent, after removing chunked transfer encoding. It is not decoded from its character set, and a compressed body is not uncompressed (the client does not ask for compression). To read the text, decode it yourself with the charset incontent_type.Header values are bytes. Most are ASCII. A server that puts UTF-8 in a header value gives you the UTF-8 bytes.
Extra request headers are sent as given. Pass them as ASCII byte strings. A header that contains a carriage return or a line feed is dropped, so it cannot add another header.
erroris a message for people. It comes from the message catalogue and may contain the operating system's text in the language of the current locale. Do not compare it with a fixed string; test only whether it is true.
LIMITATIONS
HTTP/1.1 only, one request per connection, no redirects, no compression, no proxy support. An interim
100 Continueresponse is returned as if it were the final one.The host name is resolved again for each connection; see "LIMITATIONS" in App::WebPerimeterScan::Scope for why that matters.
An IPv6 literal host is passed to the socket layer as given but the
Hostheader does not bracket it.
FORMAL SPECIFICATION
This section states exactly what each method does, in the Z notation. You do not need it to use the module: the method descriptions above say the same thing in words. It is here for reviewers and for anyone who writes tests.
How to read a schema box:
The part above the middle line declares names. A name that ends in
?is an input. A name that ends in!is an output.The part below the middle line lists facts that are true after the call.
A name with a dash after it, such as
items', is the value after the call. The same name without the dash is the value before the call.Delta X(written with the Greek capital delta) means the call may change the stateX.Xi X(the Greek capital xi) means the call does not change it.
The symbols used are:
Δ may change Ξ does not change • such that
∀ for all ∃ there is ¬ not
∧ and ∨ or ⇒ implies
⇔ if and only if ≠ not equal ≤ ≥ at most, at least
∈ is in ¬in; is not in ⊆ is a subset of
∪ union ∩ intersection ∅ the empty set
ℙ X sets of X ⊥ undefined (undef) … and so on
⟨⟩ the empty sequence ⁀ join sequences ⇸ partial mapping
↦ maps to ⊕ override a mapping ↾ keep only these keys
∘ compose → function × ÷ − times, divide, minus
ℕ 0, 1, 2, ... ℕ₁ 1, 2, 3, ... ℝ real numbers
𝔹 true or false ⌊x⌋ x rounded down
dom M the keys of M ran M the values of M #S the size of S
new
┌─ InitHTTP ──────────────────────────────────────────
│ HTTP'; timeout?, delay? : ℝ; max_size? : ℕ₁
├─────────────────────────────────────────────────────
│ timeout? ≥ 0 ∧ delay? ≥ 0
│ requests' = 0 ∧ last' = ∅
└─────────────────────────────────────────────────────
requests
┌─ Requests ──────────────────────────────────────────
│ ΞHTTP; result! : ℕ
├─────────────────────────────────────────────────────
│ result! = requests
└─────────────────────────────────────────────────────
fetch
┌─ Fetch ─────────────────────────────────────────────
│ ΔHTTP; url? : URL; method? : TOKEN; res! : RESPONSE
├─────────────────────────────────────────────────────
│ scheme(url?) ∈ {http, https} ∧ host(url?) ≠ ∅
│ now − last(host(url?)) ≥ delay (after any sleep)
│ connected ⇒ requests' = requests + 1
│ ¬ connected ⇒ requests' = requests ∧ error ∈ dom res!
│ #res!.body ≤ max_size
└─────────────────────────────────────────────────────
header
┌─ Header ────────────────────────────────────────────
│ response? : RESPONSE; name? : STRING; result! : STRING ∪ {⊥}
├─────────────────────────────────────────────────────
│ n = lower(name?)
│ n ∈ dom response?.headers ⇒ result! = last(response?.headers(n))
│ n ¬in; dom response?.headers ⇒ result! = ⊥
└─────────────────────────────────────────────────────
STATE DIAGRAM
The object keeps two pieces of state: requests, the number of connections that opened, and the time of the last request to each host. Each call to fetch goes through the steps below. A step that fails ends the call with a result that has an error field.
new(timeout, delay, max_size)
|
v
+-------------+
| READY | requests = 0 at first; no host seen yet
+-------------+
|
| fetch(url, method, headers)
v
+-------------+
| VALIDATE |--- bad method or unknown argument
+-------------+ -> croak; back to READY, no change
|
|---------- URL not http or https, or it has no host
| -> result {error}; back to READY
v
+-------------+
| WAIT | sleep until 'delay' seconds have passed since
+-------------+ the last request to this host; then record the
| time of this request for the host
v
+-------------+
| CONNECT |--- refused, timeout, or TLS handshake failed
+-------------+ -> result {error}; back to READY
|
| connected: requests = requests + 1
v
+-------------+
| SEND |--- write failed or timed out
+-------------+ -> result {error}; back to READY
|
| request line, Host, User-Agent and extra headers sent
v
+-------------+
| READ HEAD |--- no blank line within 64 KiB or before the
+-------------+ deadline, or a status line that is not HTTP
| -> result {error}; back to READY
v
+-------------+
| READ BODY | by Content-Length, by chunks, or to the end of
+-------------+ the stream; stop at max_size (truncated = 1);
| no body for HEAD, 1xx, 204 or 304
v
result {status, headers, body, ...}; back to READY
The connection is closed before fetch returns, on every path after
CONNECT.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.