File Coverage

lib/PAGI/Middleware/CORS.pm
Criterion Covered Total %
statement 78 84 92.8
branch 18 26 69.2
condition 18 23 78.2
subroutine 11 11 100.0
pod 1 1 100.0
total 126 145 86.9


line stmt bran cond sub pod time code
1             package PAGI::Middleware::CORS;
2              
3 2     2   341041 use strict;
  2         2  
  2         59  
4 2     2   7 use warnings;
  2         7  
  2         94  
5 2     2   651 use parent 'PAGI::Middleware';
  2         544  
  2         9  
6 2     2   102 use Future::AsyncAwait;
  2         2  
  2         9  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::CORS - Cross-Origin Resource Sharing middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'CORS',
18             origins => ['https://example.com', 'https://app.example.com'],
19             methods => ['GET', 'POST', 'PUT', 'DELETE'],
20             headers => ['Content-Type', 'Authorization'],
21             credentials => 1,
22             max_age => 86400;
23             $my_app;
24             };
25              
26             =head1 DESCRIPTION
27              
28             PAGI::Middleware::CORS implements Cross-Origin Resource Sharing (CORS)
29             for PAGI applications. It handles preflight OPTIONS requests and adds
30             the appropriate CORS headers to responses.
31              
32             =head1 CONFIGURATION
33              
34             =over 4
35              
36             =item * origins (default: ['*'])
37              
38             Array of allowed origins, or ['*'] for any origin.
39              
40             =item * methods (default: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
41              
42             Array of allowed HTTP methods.
43              
44             =item * headers (default: ['Content-Type', 'Authorization', 'X-Requested-With'])
45              
46             Array of allowed request headers.
47              
48             =item * expose_headers (default: [])
49              
50             Array of headers to expose to the client.
51              
52             =item * credentials (default: 0)
53              
54             If true, allow credentials (cookies, auth headers).
55              
56             =item * max_age (default: 86400)
57              
58             Max age for preflight cache in seconds.
59              
60             =back
61              
62             =cut
63              
64             sub _init {
65 7     7   10 my ($self, $config) = @_;
66              
67 7   100     28 $self->{origins} = $config->{origins} // ['*'];
68 7   100     32 $self->{methods} = $config->{methods} // [qw(GET POST PUT DELETE PATCH OPTIONS)];
69 7   100     26 $self->{headers} = $config->{headers} // [qw(Content-Type Authorization X-Requested-With)];
70 7   50     36 $self->{expose_headers} = $config->{expose_headers} // [];
71 7   100     19 $self->{credentials} = $config->{credentials} // 0;
72 7   50     23 $self->{max_age} = $config->{max_age} // 86400;
73              
74 7 100 100     23 if ($self->{credentials} && grep { $_ eq '*' } @{$self->{origins}}) {
  3         13  
  3         6  
75 1         11 warn "PAGI::Middleware::CORS: wildcard origins ('*') with credentials "
76             . "enabled reflects any Origin with Access-Control-Allow-Credentials. "
77             . "This allows any website to make credentialed cross-origin requests. "
78             . "Consider specifying explicit origins.\n";
79             }
80             }
81              
82             sub wrap {
83 3     3 1 23 my ($self, $app) = @_;
84              
85 3     3   52 return async sub {
86 3         4 my ($scope, $receive, $send) = @_;
87             # Only handle HTTP requests
88 3 50       8 if ($scope->{type} ne 'http') {
89 0         0 await $app->($scope, $receive, $send);
90 0         0 return;
91             }
92              
93             # Get Origin header
94 3         21 my $origin = $self->_get_header($scope, 'origin');
95              
96             # Check if this is a preflight request
97 3 100 66     10 if ($scope->{method} eq 'OPTIONS' && $origin) {
98 1         3 await $self->_handle_preflight($scope, $send, $origin);
99 1         49 return;
100             }
101              
102             # For actual requests, add CORS headers to response
103 2 100 66     8 if ($origin && $self->_is_origin_allowed($origin)) {
104 2         71 my $wrapped_send = async sub {
105 2         3 my ($event) = @_;
106 2 100       5 if ($event->{type} eq 'http.response.start') {
107 1         2 $self->_add_cors_headers($event->{headers}, $origin);
108             }
109 2         3 await $send->($event);
110 1         4 };
111 1         2 await $app->($scope, $receive, $wrapped_send);
112             } else {
113 1         2 await $app->($scope, $receive, $send);
114             }
115 3         12 };
116             }
117              
118 1     1   2 async sub _handle_preflight {
119 1         2 my ($self, $scope, $send, $origin) = @_;
120              
121 1         1 my @headers;
122              
123 1 50       3 if ($self->_is_origin_allowed($origin)) {
124 1         4 $self->_add_cors_headers(\@headers, $origin);
125              
126             # Add preflight-specific headers
127 1         2 push @headers, ['Access-Control-Allow-Methods', join(', ', @{$self->{methods}})];
  1         3  
128 1         2 push @headers, ['Access-Control-Allow-Headers', join(', ', @{$self->{headers}})];
  1         3  
129 1         3 push @headers, ['Access-Control-Max-Age', $self->{max_age}];
130             }
131              
132             await $send->({
133             type => 'http.response.start',
134             status => 204,
135             headers => \@headers,
136 1         5 });
137              
138 1         87 await $send->({
139             type => 'http.response.body',
140             body => '',
141             more => 0,
142             });
143             }
144              
145             sub _add_cors_headers {
146 2     2   5 my ($self, $headers, $origin) = @_;
147              
148             # Determine origin to return
149 2         1 my $allowed_origin;
150 2 50       2 if (grep { $_ eq '*' } @{$self->{origins}}) {
  2         4  
  2         4  
151 0 0       0 $allowed_origin = $self->{credentials} ? $origin : '*';
152             } else {
153 2         4 $allowed_origin = $origin;
154             }
155              
156 2         3 push @$headers, ['Access-Control-Allow-Origin', $allowed_origin];
157              
158 2 100       4 if ($self->{credentials}) {
159 1         2 push @$headers, ['Access-Control-Allow-Credentials', 'true'];
160             }
161              
162 2 50       3 if (@{$self->{expose_headers}}) {
  2         3  
163 0         0 push @$headers, ['Access-Control-Expose-Headers', join(', ', @{$self->{expose_headers}})];
  0         0  
164             }
165              
166             # Vary header for caching
167 2         16 push @$headers, ['Vary', 'Origin'];
168             }
169              
170             sub _is_origin_allowed {
171 3     3   4 my ($self, $origin) = @_;
172              
173 3 50       3 return 1 if grep { $_ eq '*' } @{$self->{origins}};
  3         8  
  3         6  
174 3 100       3 return 1 if grep { $_ eq $origin } @{$self->{origins}};
  3         10  
  3         5  
175 1         4 return 0;
176             }
177              
178             sub _get_header {
179 3     3   4 my ($self, $scope, $name) = @_;
180              
181 3         5 $name = lc($name);
182 3   50     4 for my $h (@{$scope->{headers} // []}) {
  3         23  
183 3 50       12 return $h->[1] if lc($h->[0]) eq $name;
184             }
185 0           return;
186             }
187              
188             1;
189              
190             __END__