File Coverage

blib/lib/PAGI/App/File.pm
Criterion Covered Total %
statement 35 101 34.6
branch 0 36 0.0
condition 6 46 13.0
subroutine 10 11 90.9
pod 0 2 0.0
total 51 196 26.0


line stmt bran cond sub pod time code
1             package PAGI::App::File;
2             $PAGI::App::File::VERSION = '0.002000';
3 3     3   1347 use strict;
  3         4  
  3         114  
4 3     3   10 use warnings;
  3         4  
  3         110  
5 3     3   10 use Future::AsyncAwait;
  3         4  
  3         12  
6 3     3   158 use Digest::MD5 qw(md5_hex);
  3         3  
  3         180  
7 3     3   13 use File::Spec;
  3         3  
  3         81  
8 3     3   9 use Cwd (); # For realpath
  3         4  
  3         5538  
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 4     4 0 9 my ($class, %args) = @_;
91              
92 4   50     14 my $root = $args{root} // '.';
93             # Resolve root to absolute path for security comparisons
94 4   33     123 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 4   50     63 handle_ranges => $args{handle_ranges} // 1,
      50        
      50        
101             }, $class;
102 4         14 return $self;
103             }
104              
105             sub to_app {
106 4     4 0 7 my ($self) = @_;
107              
108 4         9 my $root = $self->{root};
109              
110 0     0   0 return async sub {
111 0         0 my ($scope, $receive, $send) = @_;
112 0 0       0 die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
113              
114 0   0     0 my $method = uc($scope->{method} // '');
115 0 0 0     0 unless ($method eq 'GET' || $method eq 'HEAD') {
116 0         0 await $self->_send_error($send, 405, 'Method Not Allowed');
117 0         0 return;
118             }
119              
120 0   0     0 my $path = $scope->{path} // '/';
121              
122             # Security: Block null byte injection
123 0 0       0 if ($path =~ /\0/) {
124 0         0 await $self->_send_error($send, 400, 'Bad Request');
125 0         0 return;
126             }
127              
128             # Security: Normalize backslashes to forward slashes
129 0         0 $path =~ s{\\}{/}g;
130              
131             # Security: Split path and validate each component
132             # Use -1 limit to preserve trailing empty strings
133 0         0 my @components = split m{/}, $path, -1;
134 0         0 for my $component (@components) {
135             # Block components with 2+ dots (.. , ..., ....)
136 0 0       0 if ($component =~ /^\.{2,}$/) {
137 0         0 await $self->_send_error($send, 403, 'Forbidden');
138 0         0 return;
139             }
140             # Block hidden files (dotfiles) - components starting with .
141 0 0 0     0 if ($component =~ /^\./ && $component ne '') {
142 0         0 await $self->_send_error($send, 403, 'Forbidden');
143 0         0 return;
144             }
145             }
146              
147             # Build file path using File::Spec for portability
148 0         0 $path =~ s{^/+}{};
149 0         0 my $file_path = File::Spec->catfile($root, $path);
150              
151             # Check for index files if directory
152 0 0       0 if (-d $file_path) {
153 0         0 for my $index (@{$self->{index}}) {
  0         0  
154 0         0 my $index_path = File::Spec->catfile($file_path, $index);
155 0 0       0 if (-f $index_path) {
156 0         0 $file_path = $index_path;
157 0         0 last;
158             }
159             }
160             }
161              
162 0 0 0     0 unless (-f $file_path && -r $file_path) {
163 0         0 await $self->_send_error($send, 404, 'Not Found');
164 0         0 return;
165             }
166              
167             # Security: Verify resolved path stays within root (prevents symlink escape)
168 0         0 my $real_path = Cwd::realpath($file_path);
169 0 0 0     0 unless ($real_path && index($real_path, $root) == 0) {
170 0         0 await $self->_send_error($send, 403, 'Forbidden');
171 0         0 return;
172             }
173              
174 0         0 my @stat = stat($file_path);
175 0         0 my $size = $stat[7];
176 0         0 my $mtime = $stat[9];
177 0         0 my $etag = '"' . md5_hex("$mtime-$size") . '"';
178              
179             # Check If-None-Match
180 0         0 my $if_none_match = $self->_get_header($scope, 'if-none-match');
181 0 0 0     0 if ($if_none_match && $if_none_match eq $etag) {
182 0         0 await $send->({
183             type => 'http.response.start',
184             status => 304,
185             headers => [['etag', $etag]],
186             });
187 0         0 await $send->({ type => 'http.response.body', body => '', more => 0 });
188 0         0 return;
189             }
190              
191             # Determine MIME type
192 0         0 my ($ext) = $file_path =~ /\.([^.]+)$/;
193 0   0     0 my $content_type = $MIME_TYPES{lc($ext // '')} // $self->{default_type};
      0        
194              
195             # Check for Range request (only if handle_ranges is enabled)
196 0 0       0 my $range = $self->{handle_ranges} ? $self->_get_header($scope, 'range') : undef;
197 0 0 0     0 if ($range && $range =~ /bytes=(\d*)-(\d*)/) {
198 0         0 my ($start, $end) = ($1, $2);
199 0 0       0 $start = 0 if $start eq '';
200 0 0 0     0 $end = $size - 1 if $end eq '' || $end >= $size;
201              
202 0 0 0     0 if ($start > $end || $start >= $size) {
203 0         0 await $self->_send_error($send, 416, 'Range Not Satisfiable');
204 0         0 return;
205             }
206              
207 0         0 my $length = $end - $start + 1;
208              
209 0         0 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 0 0       0 if ($method eq 'HEAD') {
223 0         0 await $send->({ type => 'http.response.body', body => '', more => 0 });
224             }
225             else {
226 0         0 await $send->({
227             type => 'http.response.body',
228             file => $file_path,
229             offset => $start,
230             length => $length,
231             });
232             }
233 0         0 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 0         0 });
247              
248             # Use file response for efficient streaming (sendfile or worker pool)
249 0 0       0 if ($method eq 'HEAD') {
250 0         0 await $send->({ type => 'http.response.body', body => '', more => 0 });
251             }
252             else {
253 0         0 await $send->({
254             type => 'http.response.body',
255             file => $file_path,
256             });
257             }
258 4         29 };
259             }
260              
261             sub _get_header {
262 1     1   2 my ($self, $scope, $name) = @_;
263              
264 1         4 $name = lc($name);
265 1   50     1 for my $h (@{$scope->{headers} // []}) {
  1         5  
266 0 0       0 return $h->[1] if lc($h->[0]) eq $name;
267             }
268 1         6 return;
269             }
270              
271 2     2   3 async sub _send_error {
272 2         5 my ($self, $send, $status, $message) = @_;
273              
274 2         11 await $send->({
275             type => 'http.response.start',
276             status => $status,
277             headers => [['content-type', 'text/plain'], ['content-length', length($message)]],
278             });
279 2         133 await $send->({ type => 'http.response.body', body => $message, more => 0 });
280             }
281              
282             1;
283              
284             __END__