File Coverage

blib/lib/PAGI/App/File.pm
Criterion Covered Total %
statement 98 101 97.0
branch 31 36 86.1
condition 27 46 58.7
subroutine 11 11 100.0
pod 0 2 0.0
total 167 196 85.2


line stmt bran cond sub pod time code
1             package PAGI::App::File;
2              
3 6     6   3088 use strict;
  6         9  
  6         208  
4 6     6   21 use warnings;
  6         11  
  6         255  
5 6     6   26 use Future::AsyncAwait;
  6         8  
  6         60  
6 6     6   302 use Digest::MD5 qw(md5_hex);
  6         26  
  6         486  
7 6     6   42 use File::Spec;
  6         10  
  6         167  
8 6     6   36 use Cwd (); # For realpath
  6         10  
  6         12007  
9              
10             =head1 NAME
11              
12             PAGI::App::File - Serve static files
13              
14             =head1 SYNOPSIS
15              
16             use PAGI::App::File;
17              
18             my $app = PAGI::App::File->new(
19             root => '/var/www/static',
20             )->to_app;
21              
22             =head1 DESCRIPTION
23              
24             PAGI::App::File serves static files from a configured root directory.
25              
26             =head2 Features
27              
28             =over 4
29              
30             =item * Efficient streaming (no memory bloat for large files)
31              
32             =item * ETag caching with If-None-Match support (304 Not Modified)
33              
34             =item * Range requests (HTTP 206 Partial Content)
35              
36             =item * Automatic MIME type detection for common file types
37              
38             =item * Index file resolution (index.html, index.htm)
39              
40             =back
41              
42             =head2 Security
43              
44             This module implements multiple layers of path traversal protection:
45              
46             =over 4
47              
48             =item * Null byte injection blocking
49              
50             =item * Double-dot and triple-dot component blocking
51              
52             =item * Backslash normalization (Windows path separator)
53              
54             =item * Hidden file blocking (dotfiles like .htaccess, .env)
55              
56             =item * Symlink escape detection via realpath verification
57              
58             =back
59              
60             =cut
61              
62             our %MIME_TYPES = (
63             html => 'text/html',
64             htm => 'text/html',
65             css => 'text/css',
66             js => 'application/javascript',
67             json => 'application/json',
68             xml => 'application/xml',
69             txt => 'text/plain',
70             pl => 'text/plain',
71             md => 'text/plain',
72             png => 'image/png',
73             jpg => 'image/jpeg',
74             jpeg => 'image/jpeg',
75             gif => 'image/gif',
76             svg => 'image/svg+xml',
77             ico => 'image/x-icon',
78             webp => 'image/webp',
79             woff => 'font/woff',
80             woff2=> 'font/woff2',
81             ttf => 'font/ttf',
82             pdf => 'application/pdf',
83             zip => 'application/zip',
84             mp3 => 'audio/mpeg',
85             mp4 => 'video/mp4',
86             webm => 'video/webm',
87             );
88              
89             sub new {
90 45     45 0 477307 my ($class, %args) = @_;
91              
92 45   50     166 my $root = $args{root} // '.';
93             # Resolve root to absolute path for security comparisons
94 45   33     1909 my $abs_root = Cwd::realpath($root) // $root;
95              
96             my $self = bless {
97             root => $abs_root,
98             default_type => $args{default_type} // 'application/octet-stream',
99             index => $args{index} // ['index.html', 'index.htm'],
100 45   50     689 handle_ranges => $args{handle_ranges} // 1,
      50        
      100        
101             }, $class;
102 45         196 return $self;
103             }
104              
105             sub to_app {
106 45     45 0 79 my ($self) = @_;
107              
108 45         87 my $root = $self->{root};
109              
110 73     73   98 return async sub {
111 73         133 my ($scope, $receive, $send) = @_;
112 73 100       510 die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
113              
114 44   50     135 my $method = uc($scope->{method} // '');
115 44 100 100     115 unless ($method eq 'GET' || $method eq 'HEAD') {
116 1         5 await $self->_send_error($send, 405, 'Method Not Allowed');
117 1         49 return;
118             }
119              
120 43   50     112 my $path = $scope->{path} // '/';
121              
122             # Security: Block null byte injection
123 43 100       120 if ($path =~ /\0/) {
124 1         4 await $self->_send_error($send, 400, 'Bad Request');
125 1         49 return;
126             }
127              
128             # Security: Normalize backslashes to forward slashes
129 42         98 $path =~ s{\\}{/}g;
130              
131             # Security: Split path and validate each component
132             # Use -1 limit to preserve trailing empty strings
133 42         190 my @components = split m{/}, $path, -1;
134 42         83 for my $component (@components) {
135             # Block components with 2+ dots (.. , ..., ....)
136 96 100       193 if ($component =~ /^\.{2,}$/) {
137 13         55 await $self->_send_error($send, 403, 'Forbidden');
138 13         703 return;
139             }
140             # Block hidden files (dotfiles) - components starting with .
141 83 100 66     155 if ($component =~ /^\./ && $component ne '') {
142 5         15 await $self->_send_error($send, 403, 'Forbidden');
143 5         275 return;
144             }
145             }
146              
147             # Build file path using File::Spec for portability
148 24         165 $path =~ s{^/+}{};
149 24         354 my $file_path = File::Spec->catfile($root, $path);
150              
151             # Check for index files if directory
152 24 100       968 if (-d $file_path) {
153 1         2 for my $index (@{$self->{index}}) {
  1         3  
154 1         12 my $index_path = File::Spec->catfile($file_path, $index);
155 1 50       30 if (-f $index_path) {
156 1         4 $file_path = $index_path;
157 1         2 last;
158             }
159             }
160             }
161              
162 24 100 66     380 unless (-f $file_path && -r $file_path) {
163 3         15 await $self->_send_error($send, 404, 'Not Found');
164 3         173 return;
165             }
166              
167             # Security: Verify resolved path stays within root (prevents symlink escape)
168 21         610 my $real_path = Cwd::realpath($file_path);
169 21 100 66     128 unless ($real_path && index($real_path, $root) == 0) {
170 1         4 await $self->_send_error($send, 403, 'Forbidden');
171 1         57 return;
172             }
173              
174 20         163 my @stat = stat($file_path);
175 20         26 my $size = $stat[7];
176 20         26 my $mtime = $stat[9];
177 20         121 my $etag = '"' . md5_hex("$mtime-$size") . '"';
178              
179             # Check If-None-Match
180 20         141 my $if_none_match = $self->_get_header($scope, 'if-none-match');
181 20 100 66     72 if ($if_none_match && $if_none_match eq $etag) {
182 2         27 await $send->({
183             type => 'http.response.start',
184             status => 304,
185             headers => [['etag', $etag]],
186             });
187 2         78 await $send->({ type => 'http.response.body', body => '', more => 0 });
188 2         81 return;
189             }
190              
191             # Determine MIME type
192 18         155 my ($ext) = $file_path =~ /\.([^.]+)$/;
193 18   50     160 my $content_type = $MIME_TYPES{lc($ext // '')} // $self->{default_type};
      66        
194              
195             # Check for Range request (only if handle_ranges is enabled)
196 18 100       65 my $range = $self->{handle_ranges} ? $self->_get_header($scope, 'range') : undef;
197 18 100 66     74 if ($range && $range =~ /bytes=(\d*)-(\d*)/) {
198 3         12 my ($start, $end) = ($1, $2);
199 3 50       12 $start = 0 if $start eq '';
200 3 50 33     24 $end = $size - 1 if $end eq '' || $end >= $size;
201              
202 3 50 33     31 if ($start > $end || $start >= $size) {
203 0         0 await $self->_send_error($send, 416, 'Range Not Satisfiable');
204 0         0 return;
205             }
206              
207 3         7 my $length = $end - $start + 1;
208              
209 3         87 await $send->({
210             type => 'http.response.start',
211             status => 206,
212             headers => [
213             ['content-type', $content_type],
214             ['content-length', $length],
215             ['content-range', "bytes $start-$end/$size"],
216             ['accept-ranges', 'bytes'],
217             ['etag', $etag],
218             ],
219             });
220              
221             # Use file response with offset/length for efficient streaming
222 3 50       150 if ($method eq 'HEAD') {
223 0         0 await $send->({ type => 'http.response.body', body => '', more => 0 });
224             }
225             else {
226 3         38 await $send->({
227             type => 'http.response.body',
228             file => $file_path,
229             offset => $start,
230             length => $length,
231             });
232             }
233 3         95 return;
234             }
235              
236             # Full file response
237             await $send->({
238             type => 'http.response.start',
239             status => 200,
240             headers => [
241             ['content-type', $content_type],
242             ['content-length', $size],
243             ['accept-ranges', 'bytes'],
244             ['etag', $etag],
245             ],
246 15         190 });
247              
248             # Use file response for efficient streaming (sendfile or worker pool)
249 15 100       624 if ($method eq 'HEAD') {
250 1         5 await $send->({ type => 'http.response.body', body => '', more => 0 });
251             }
252             else {
253 14         53 await $send->({
254             type => 'http.response.body',
255             file => $file_path,
256             });
257             }
258 45         551 };
259             }
260              
261             sub _get_header {
262 40     40   145 my ($self, $scope, $name) = @_;
263              
264 40         74 $name = lc($name);
265 40   50     42 for my $h (@{$scope->{headers} // []}) {
  40         107  
266 115 100       222 return $h->[1] if lc($h->[0]) eq $name;
267             }
268 34         63 return;
269             }
270              
271 26     26   31 async sub _send_error {
272 26         54 my ($self, $send, $status, $message) = @_;
273              
274 26         202 await $send->({
275             type => 'http.response.start',
276             status => $status,
277             headers => [['content-type', 'text/plain'], ['content-length', length($message)]],
278             });
279 26         1085 await $send->({ type => 'http.response.body', body => $message, more => 0 });
280             }
281              
282             1;
283              
284             __END__