File Coverage

blib/lib/Plack/Middleware/ProofOfWork.pm
Criterion Covered Total %
statement 140 194 72.1
branch 34 80 42.5
condition 11 31 35.4
subroutine 23 28 82.1
pod 2 2 100.0
total 210 335 62.6


line stmt bran cond sub pod time code
1             package Plack::Middleware::ProofOfWork;
2              
3 1     1   319074 use strict;
  1         4  
  1         84  
4 1     1   7 use warnings;
  1         1  
  1         92  
5 1     1   6 use parent qw(Plack::Middleware);
  1         2  
  1         8  
6 1         10 use Plack::Util::Accessor qw(
7             difficulty
8             cookie_name
9             cookie_duration
10             bot_patterns
11             bot_verification_level
12             bot_dns_timeout
13             timestamp_window
14             js_file
15             html_file
16             css
17             _js_content
18             _html_content
19 1     1   129 );
  1         3  
20 1     1   211 use Digest::SHA qw(sha256_hex);
  1         2  
  1         112  
21 1     1   10 use MIME::Base64 qw(decode_base64);
  1         2  
  1         53  
22 1     1   638 use Plack::Request;
  1         64063  
  1         51  
23 1     1   678 use Plack::Response;
  1         1680  
  1         43  
24 1     1   685 use File::ShareDir ();
  1         26730  
  1         30  
25 1     1   7 use File::Spec;
  1         2  
  1         37  
26 1     1   498 use Socket qw(:addrinfo SOCK_RAW AF_INET AF_INET6 NI_NUMERICHOST inet_pton inet_ntop);
  1         3376  
  1         334  
27 1     1   7 use Time::HiRes qw(alarm);
  1         2  
  1         7  
28              
29             our $VERSION = '0.24';
30              
31             sub prepare_app {
32 4     4 1 5030 my $self = shift;
33            
34             # Set default values
35 4 50       16 $self->difficulty(4) unless defined $self->difficulty;
36 4 50       61 $self->cookie_name('pow') unless defined $self->cookie_name;
37 4 50       24 $self->cookie_duration(5) unless defined $self->cookie_duration; # Days
38 4 50       25 $self->bot_verification_level(2) unless defined $self->bot_verification_level; # 0-3, default 2
39 4 50       23 $self->bot_dns_timeout(0.5) unless defined $self->bot_dns_timeout; # Seconds
40 4 50       47 $self->timestamp_window(86400 * $self->cookie_duration) unless defined $self->timestamp_window;
41            
42             # Default bot patterns with DNS verification patterns
43 4 50       48 unless (defined $self->bot_patterns) {
44 4         94 $self->bot_patterns({
45             'googlebot/' => qr/crawl.*\.googlebot\.com$/,
46             'applebot/' => qr/applebot\.apple\.com$/,
47             'bingbot/' => qr/bingbot.*\.bing\.com$/,
48             'yandexbot/' => qr/\.yandex\.(ru|net|com)$/,
49             'petalbot' => qr/petalbot.*\.petalsearch\.com$/,
50             'ahrefsbot/' => qr/\.ahrefs\.(com|net)$/,
51             'barkrowler/' => qr/\.babbar\.eu$/,
52             'claudebot/' => qr/^%$/,
53             'oai-searchbot/' => qr/^%$/,
54             'gptbot/' => qr/^%$/,
55             'mj12bot/' => qr/^%$/,
56             'amazonbot/' => qr/^%$/,
57             'perplexitybot/' => qr/^%$/,
58             'duckduckbot/' => qr/^%$/,
59             });
60             }
61            
62             # Load JavaScript template at startup
63 4 50       28 unless (defined $self->js_file) {
64 4         21 $self->js_file($self->_find_share_file('pow.js'));
65             }
66 4         25 $self->_js_content($self->_load_template_file($self->js_file));
67            
68             # Load HTML template at startup
69 4 50       56 unless (defined $self->html_file) {
70 4         19 $self->html_file($self->_find_share_file('challenge.html'));
71             }
72 4         33 $self->_html_content($self->_load_template_file($self->html_file));
73             }
74              
75             sub call {
76 8     8 1 36938 my ($self, $env) = @_;
77 8         47 my $req = Plack::Request->new($env);
78            
79             # Check if PoW is required
80 8 100       68 if ($self->_needs_proof_of_work($req)) {
81 4         10 return $self->_serve_challenge($req);
82             }
83            
84             # PoW successful or not required - pass through
85 4         15 return $self->app->($env);
86             }
87              
88             sub _needs_proof_of_work {
89 8     8   15 my ($self, $req) = @_;
90            
91             # Check PoW cookie FIRST (before bot check)
92 8         20 my $pow_cookie = $req->cookies->{$self->cookie_name};
93            
94             # If valid cookie exists, validate it
95 8 100       315 if (defined $pow_cookie) {
96             # Valid PoW - allow through
97 4 100       14 return 0 if $self->_verify_proof_of_work($req, $pow_cookie);
98             # Invalid PoW - require new one (even for bots)
99 2         5 return 1;
100             }
101            
102             # No cookie - check if bot exception applies
103 4 100       22 if ($self->_is_bot($req)) {
104 2         5 return 0; # Verified bot - allow through
105             }
106            
107             # No valid cookie and not a bot - require PoW
108 2         5 return 1;
109             }
110              
111             sub _is_bot {
112 4     4   9 my ($self, $req) = @_;
113            
114 4         11 my $verification_level = $self->bot_verification_level;
115 4 100       19 return 0 if $verification_level == 0;
116            
117 3   100     9 my $user_agent = $req->user_agent || '';
118 3   0     401 my $remote_addr = $req->address || $req->env->{REMOTE_ADDR} || '';
119            
120             # Find matching bot type
121 3         19 my $bot_type;
122 3         8 my $bot_patterns = $self->bot_patterns;
123            
124 3         21 foreach my $key (keys %$bot_patterns) {
125 23 100       153 if ($user_agent =~ /\Q$key\E/i) {
126 2         3 $bot_type = $key;
127 2         4 last;
128             }
129             }
130            
131 3 100       20 return 0 unless $bot_type;
132 2 50       7 return 1 if $verification_level == 1; # Only User-Agent check
133            
134             # Level 2+: DNS verification
135 0 0       0 return 0 unless $remote_addr;
136            
137 0         0 my $hostname = $self->_get_hostname($remote_addr);
138 0 0       0 return 0 unless $hostname;
139 0 0       0 return 0 unless $hostname =~ $bot_patterns->{$bot_type};
140 0 0       0 return 1 if $verification_level == 2; # Reverse DNS only
141            
142             # Level 3: Full DNS roundtrip verification
143 0         0 return $self->_verify_dns_match($remote_addr, $hostname);
144             }
145              
146             sub _normalize_ip {
147 0     0   0 my ($self, $ip) = @_;
148 0 0       0 my $family = $ip =~ /:/ ? AF_INET6 : AF_INET;
149 0 0       0 my $packed = inet_pton($family, $ip) or return $ip;
150 0         0 return inet_ntop($family, $packed);
151             }
152              
153             sub _get_hostname {
154 0     0   0 my ($self, $ip) = @_;
155            
156 0         0 my $hostname;
157 0         0 my $timeout = $self->bot_dns_timeout;
158            
159 0         0 eval {
160 0     0   0 local $SIG{ALRM} = sub { die "timeout\n" };
  0         0  
161 0         0 alarm($timeout);
162            
163 0 0       0 if ($ip =~ /:/) {
164 0 0       0 my $packed = inet_pton(AF_INET6, $ip) or die "invalid ip\n";
165 0         0 $hostname = gethostbyaddr($packed, AF_INET6);
166             } else {
167 0 0       0 my $packed = inet_pton(AF_INET, $ip) or die "invalid ip\n";
168 0         0 $hostname = gethostbyaddr($packed, AF_INET);
169             }
170            
171 0         0 alarm(0);
172             };
173            
174 0         0 alarm(0);
175            
176 0 0 0     0 return undef if ($@ && $@ =~ /timeout/);
177 0         0 return $hostname;
178             }
179              
180             sub _verify_dns_match {
181 0     0   0 my ($self, $ip, $hostname) = @_;
182            
183 0         0 my @resolved_ips;
184 0         0 my $success = 0;
185 0         0 my $timeout = $self->bot_dns_timeout * 2;
186            
187 0         0 eval {
188 0     0   0 local $SIG{ALRM} = sub { die "timeout\n" };
  0         0  
189 0         0 alarm($timeout);
190            
191 0         0 my ($err, @res) = getaddrinfo($hostname, "", {socktype => SOCK_RAW});
192            
193 0 0       0 if (!$err) {
194             @resolved_ips = map {
195 0         0 my ($err_ni, $resolved_ip) = getnameinfo($_->{addr}, NI_NUMERICHOST);
  0         0  
196 0 0       0 $err_ni ? () : $resolved_ip;
197             } @res;
198            
199 0         0 $success = 1;
200             }
201            
202 0         0 alarm(0);
203             };
204            
205 0         0 alarm(0);
206            
207 0 0 0     0 return 0 if ($@ && $@ =~ /timeout/);
208 0 0       0 return 0 unless $success;
209            
210 0         0 my $normalized_ip = $self->_normalize_ip($ip);
211 0         0 my $found_match = grep { $self->_normalize_ip($_) eq $normalized_ip } @resolved_ips;
  0         0  
212            
213 0         0 return $found_match;
214             }
215              
216             sub _verify_proof_of_work {
217 4     4   10 my ($self, $req, $pow_cookie) = @_;
218            
219             # Decode cookie (can fail)
220 4         5 my $nonce;
221 4         8 eval {
222 4         33 $nonce = decode_base64($pow_cookie);
223             };
224 4 50       12 return 0 if $@; # Decode failed
225            
226             # Generate source value
227 4         12 my $source = $self->_get_source_value($req);
228            
229             # Calculate hash
230 4         7 my $input = "$source:$nonce";
231 4         22 my $hash = sha256_hex($input);
232            
233             # Check leading zeros with fractional difficulty
234 4         10 my $difficulty = $self->difficulty;
235 4         13 my $full = int($difficulty);
236 4         6 my $fraction = $difficulty - $full;
237            
238             # Check integer part
239 4         8 my $required_zeros = '0' x $full;
240 4 100       31 return 0 unless $hash =~ /^$required_zeros/;
241            
242             # Check fractional part (if present)
243 2 100       31 if ($fraction > 0) {
244 1         3 my $next_char = substr($hash, $full, 1);
245 1         2 my $hex_value = hex($next_char);
246 1         3 my $div = 16 - (16 * $fraction);
247 1 50       3 return 0 unless $hex_value < $div;
248             }
249            
250             # Valid proof-of-work
251 2         9 return 1;
252             }
253              
254             sub _get_source_value {
255 8     8   13 my ($self, $req) = @_;
256            
257 8   100     19 my $user_agent = $req->user_agent || 'Unknown';
258 8   100     870 my $accept_language = $req->header('Accept-Language') || 'Empty';
259 8   50     235 my $host = $req->header('Host') || 'Unknown';
260            
261             # Round timestamp to cookie duration
262 8         184 my $now = time();
263 8         21 my $timestamp = $now - ($now % $self->timestamp_window);
264            
265 8         48 return "$user_agent|$timestamp|$accept_language|$host";
266             }
267              
268             sub _find_share_file {
269 8     8   19 my ($self, $filename) = @_;
270            
271             # Try installed share files (primary method)
272 8         11 my $dist_file;
273 8         12 eval {
274 8         28 $dist_file = File::ShareDir::dist_file('Plack-Middleware-ProofOfWork', $filename);
275             };
276            
277             # Check if we got a valid file from File::ShareDir
278 8 50 33     1381 if (!$@ && defined $dist_file && -f $dist_file && -r $dist_file) {
      33        
      33        
279 8         35 return $dist_file;
280             }
281            
282             # Development paths (for local development)
283 0         0 my @dev_paths = (
284             "share/$filename",
285             "./share/$filename",
286             );
287            
288 0         0 for my $path (@dev_paths) {
289 0 0 0     0 return $path if -f $path && -r $path;
290             }
291            
292             # File not found
293 0         0 die "Template file '$filename' not found. Please ensure Plack::Middleware::ProofOfWork is properly installed.";
294             }
295              
296             sub _load_template_file {
297 8     8   39 my ($self, $filepath) = @_;
298            
299             # Check if file exists
300 8 50 33     164 unless (-f $filepath && -r $filepath) {
301 0         0 die "Template file '$filepath' not found or not readable.";
302             }
303            
304             # Load template file
305 1 50   1   619 open my $fh, '<:encoding(UTF-8)', $filepath
  1         13  
  1         4  
  8         290  
306             or die "Cannot open template file '$filepath': $!";
307            
308 8         1311 my $content = do { local $/; <$fh> };
  8         28  
  8         174  
309 8         206 close $fh;
310            
311 8         68 return $content;
312             }
313              
314             sub _serve_challenge {
315 4     4   8 my ($self, $req) = @_;
316            
317 4         9 my $source_value = $self->_get_source_value($req);
318 4         13 my $html = $self->_generate_challenge_html($source_value);
319            
320 4         31 my $res = Plack::Response->new(200);
321 4         72 $res->content_type('text/html; charset=utf-8');
322 4         111 $res->header('X-Proof-of-Work' => 'required');
323 4         131 $res->body($html);
324            
325 4         20 return $res->finalize;
326             }
327              
328             sub _generate_challenge_html {
329 4     4   7 my ($self, $source_value) = @_;
330            
331 4         11 my $difficulty = $self->difficulty;
332 4         14 my $cookie_name = $self->cookie_name;
333 4         16 my $cookie_duration = $self->cookie_duration;
334            
335             # Escape for JavaScript
336 4         16 $source_value =~ s/\\/\\\\/g;
337 4         6 $source_value =~ s/"/\\"/g;
338            
339             # Use preloaded JavaScript content
340 4         9 my $js_content = $self->_js_content;
341            
342             # API prefix: Constants and getSourceValue() function
343 4         34 my $js_api_prefix = <<"JSAPI";
344             // ============================================================================
345             // Plack::Middleware::ProofOfWork API
346             // ============================================================================
347             // These constants and functions are provided by the middleware
348             // and must be used by the pow.js script.
349              
350             // Constants
351             const DIFFICULTY = $difficulty;
352             const POW_COOKIE_NAME = '$cookie_name';
353             const COOKIE_DURATION = $cookie_duration;
354              
355             // API function: Returns the source value for PoW calculation
356             function getSourceValue() {
357             return "$source_value";
358             }
359              
360             // ============================================================================
361             // End of API - pow.js script begins here
362             // ============================================================================
363             $js_content
364             JSAPI
365              
366             # Use preloaded HTML template
367 4         15 my $html_template = $self->_html_content;
368            
369             # Replace JavaScript placeholder
370 4         86 $html_template =~ s//$js_api_prefix/;
371            
372             # Replace CSS placeholder if custom CSS provided
373 4 50       13 if (my $custom_css = $self->css) {
374 0         0 $html_template =~ s//$custom_css/;
375             }
376            
377 4         48 return $html_template;
378             }
379              
380             1;
381              
382             __END__