├── .appveyor.yml
├── .github
└── CONTRIBUTING.md
├── ChangeLogs
└── ChangeLog-2.0.md
├── LICENSE.md
├── README.md
├── composer.json
├── demo
└── CustomSecureHeaders.php
└── src
├── Error.php
├── ExposesErrors.php
├── Header.php
├── HeaderBag.php
├── HeaderFactory.php
├── Headers
├── AbstractHeader.php
├── CSPHeader.php
└── RegularHeader.php
├── Http
├── GlobalHttpAdapter.php
├── HttpAdapter.php
├── Psr7Adapter.php
└── StringHttpAdapter.php
├── Operation.php
├── Operations
├── AddHeader.php
├── ApplySafeMode.php
├── CompileCSP.php
├── CompileExpectCT.php
├── CompileHPKP.php
├── CompileHSTS.php
├── InjectStrictDynamic.php
├── ModifyCookies.php
├── OperationWithErrors.php
├── RemoveCookies.php
└── RemoveHeaders.php
├── SecureHeaders.php
├── Util
├── TypeError.php
└── Types.php
├── Validator.php
├── ValidatorDelegate.php
└── ValidatorDelegates
├── CSPBadFlags.php
├── CSPRODestination.php
└── CSPWildcards.php
/.appveyor.yml:
--------------------------------------------------------------------------------
1 | build: false
2 | version: dev-{build}
3 | shallow_clone: false
4 | clone_folder: C:\projects\secureheaders
5 |
6 | environment:
7 | matrix:
8 | - php_ver: 7.1.6
9 | - php_ver: 7.0.20
10 | - php_ver: 5.6.30
11 | - php_ver: 5.5.38
12 | - php_ver: 5.4.45
13 |
14 | cache:
15 | - '%APPDATA%\Composer'
16 | - '%LOCALAPPDATA%\Composer'
17 | - C:\tools\php -> .appveyor.yml
18 | - C:\tools\composer.phar -> .appveyor.yml
19 |
20 | init:
21 | - SET PATH=C:\tools\php;%PATH%
22 |
23 | install:
24 | - ps: Set-Service wuauserv -StartupType Manual
25 | - IF NOT EXIST C:\tools\php (choco install --yes --allow-empty-checksums php --version %php_ver% --params '/InstallDir:C:\tools\php')
26 | - cd C:\tools\php
27 | - copy php.ini-production php.ini
28 | - echo date.timezone="UTC" >> php.ini
29 | - echo memory_limit=512M >> php.ini
30 | - echo extension_dir=ext >> php.ini
31 | - echo extension=php_curl.dll >> php.ini
32 | - echo extension=php_openssl.dll >> php.ini
33 | - echo extension=php_mbstring.dll >> php.ini
34 | - echo extension=php_fileinfo.dll >> php.ini
35 | - IF NOT EXIST C:\tools\composer.phar (cd C:\tools && appveyor DownloadFile https://getcomposer.org/composer.phar)
36 |
37 | before_test:
38 | - cd C:\projects\secureheaders
39 | - php C:\tools\composer.phar config repositories.0 --unset --no-interaction
40 | - php C:\tools\composer.phar remove friendsofphp/php-cs-fixer --dev --no-interaction --no-progress --no-ansi
41 | - php C:\tools\composer.phar update --no-interaction --no-progress --optimize-autoloader --prefer-stable --no-ansi
42 |
43 | test_script:
44 | - cd C:\projects\secureheaders
45 | - vendor\bin\phpunit --verbose
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 | Hi there! Thanks for considering making a contribution to SecureHeaders. PRs are
3 | always welcome! 😀
4 |
5 | ## Pull-Request Checklist
6 | Try to complete as many of these as possible before submitting a PR, but none
7 | are manditory to submit (we can work to get everything checked off before merge
8 | once the PR is submitted).
9 |
10 | 1. If you've added a new feature/fixed a bug/changed how SecureHeaders works
11 | in some way, it would be great if you added some tests to make sure your
12 | code 1. functions as expected now, 2. functions as expected when others make
13 | changes later.
14 |
15 | You should be able to find the general format for adding tests in the
16 | `tests/` folder in the root of this repo.
17 | 2. Make sure all the tests pass. These will be checked automagically when you
18 | submit a PR, but if you'd like to test locally – run `vendor/bin/phpunit`.
19 | 3. Make sure code styling matches [spec](#Code-Styling). In general, most code styling
20 | discrepancies can be fixed by running `vendor/bin/php-cs-fixer fix .`, but
21 | please take a look at the [Code Styling](#Code-Styling) guide anyway.
22 | 4. Make sure [Coding Conventions](#Coding-Conventions) are followed.
23 | 5. Optionally, include a summary of your work in the upcoming changelog file
24 | located in `ChangeLogs/`.
25 |
26 |
27 | ### Code Styling
28 | Code styling in this repo follows PSR-2 generally, with the following
29 | exceptions and additions:
30 | 1. All opening braces (`{`) must start on the **next** line
31 | e.g.
32 | ```php
33 | foreach ($Foo as $bar)
34 | {
35 | # do something
36 | }
37 | ```
38 | However, if an immediately preceding closing (`)`) is on its own line then
39 | you must place the opening (`{`) on the same line seperated by a single
40 | space. e.g.
41 | ```php
42 | public function hpkp(
43 | $pins,
44 | $maxAge = null,
45 | $subdomains = null,
46 | $reportUri = null,
47 | $reportOnly = null
48 | ) {
49 | ```
50 |
51 | 2. The not operator (`!`) must have whitespace on either side
52 | 3. Short array syntax must be used (`[]` and not `array()`)
53 | 4. Single-line text comments must use `#` and not `//`
54 | 5. Commented out code (if it is ever appropriate in the repo) must use
55 | `//` on each line and not `#`
56 |
57 | ### Coding Conventions
58 | The following conventions are to be followed:
59 | 1. Aggressive type hints (compatible with PHP 5.4):
60 | * If you can type hint with PHP in function arguments, you should do that;
61 | otherwise
62 | * If the type of a variable must be of a certain type to work, and it is
63 | passed through a function, you must use the built in type assersion
64 | helper, e.g.
65 | ```php
66 | public function hpkp(
67 | $pins,
68 | $maxAge = null,
69 | $subdomains = null,
70 | $reportUri = null,
71 | $reportOnly = null
72 | ) {
73 | Types::assert(
74 | [
75 | 'string|array' => [$pins],
76 | 'int|string' => [$maxAge],
77 | 'string' => [$reportUri]
78 | ],
79 | [1, 2, 4]
80 | );
81 | ```
82 |
83 | The first argument of `Types::assert()` is an array of types mapping to an
84 | array of variables for which to assert the type. Multiple type allowances
85 | are seperated with a pipe (`|`).
86 | The second argument is an array of argument numbers, referring to the
87 | arguement numbers of the varibles in the order that they are given
88 | in the first array (this is used for debugging purposes so the argument
89 | number can be given).
90 |
91 | If all the arguments are given in order with no gaps, starting from one
92 | then the second array detailing the argument numbers may be omitted.
93 |
94 | Here's an other example:
95 | ```php
96 | public function cspHash(
97 | $friendlyDirective,
98 | $string,
99 | $algo = null,
100 | $isFile = null,
101 | $reportOnly = null
102 | ) {
103 | Types::assert(
104 | ['string' => [$friendlyDirective, $string, $algo]]
105 | );
106 | ```
107 |
--------------------------------------------------------------------------------
/ChangeLogs/ChangeLog-2.0.md:
--------------------------------------------------------------------------------
1 | # Changes in SecureHeaders 2.0
2 |
3 | All notable changes of the SecureHeaders 2.0 release series are documented in
4 | this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
5 |
6 | ## [2.0.1] - *2017-08-28*
7 | ### Fixed
8 | * Fix bug where header with "falsey" value would not be properly set
9 | * Ensure `strict-dynamic` is also opportunistically injected into the report
10 | only CSP; add missing options to control this behaviour
11 |
12 | ## [2.0] - *2017-07-16*
13 |
14 | ### Added
15 | * You can now easily integrate SecureHeaders with arbitrary frameworks by
16 | implementing the HttpAdapter (`Aidantwoods\SecureHeaders\Http\HttpAdapter`).
17 |
18 | * Better cookie upgrades:
19 | Specifically incorporating the[`SameSite`](https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1)
20 | cookie attribute. `SameSite=Lax` will be added in alongside the
21 | `HttpOnly` and `Secure` flags to sensitive looking cookies by default, and will
22 | be upgraded to `SameSite=Strict` if operating in
23 | [`strictMode`](https://github.com/aidantwoods/SecureHeaders/wiki/strictMode).
24 |
25 | * Add a new header by default:
26 | The new header being [`X-Permitted-Cross-Domain-Policies: none`](https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#X-Permitted-Cross-Domain-Policies).
27 | As with other automatic headers, this will be done via a
28 | [header proposal](https://github.com/aidantwoods/SecureHeaders/wiki/header-proposals)
29 | – so this can be explicitly removed or modified as you prefer if the default
30 | is not desired.
31 |
32 | * Add a new header by default:
33 | `Referrer-Policy: strict-origin-when-cross-origin` with a fallback policy of
34 | `no-referrer`.
35 | I've made `no-referrer` the fallback because is the only policy value
36 | (currently) supported by both Chrome and FF which guarantees that the full
37 | query string will remain private on cross-origin requests, and that no URL is
38 | leaked over the network on insecure requests (to the same origin).
39 |
40 | * Add a new header by default: `Expect-CT: max-age=0`.
41 | [Spec here](https://datatracker.ietf.org/doc/draft-stark-expect-ct/).
42 | This defaults to reporting mode, but will be configurable to operate in
43 | enforce mode, or just reporting with some `report-uri` specified.
44 |
45 | I think it's a good idea to initially set `Expect-CT: max-age=0` so that
46 | (when browsers support it) they will start to warn if the CT requirements
47 | are not met (presumably in the browser console). Note that by not including
48 | the enforce directive here, browsers will not `enforce` and only warn – so
49 | there's no risk of causing sites downtime if they don't meet the requirements.
50 |
51 | ### Changed
52 | * SecureHeaders is now intended to be a composer library, meaning that the
53 | single `SecureHeaders.php` will no longer contain the whole library. However,
54 | you may now instead download and include/require the entire library via
55 | the `SecureHeaders.phar` release.
56 |
57 | * The SecureHeaders class is now namespaced to
58 | `Aidantwoods\SecureHeaders\SecureHeaders;`
59 |
60 | * Strict Mode now includes injecting the `SameSite` cookie attribute.
61 |
62 | * Strict Mode now includes the `Expect-CT: max-age=31536000; enforce`
63 | as a header proposal.
64 |
65 | * If SecureHeaders throws an exception, it'll only auto-send the headers when
66 | emitting that exception if `applyOnOutput` has been enabled (it is not on
67 | by default).
68 |
69 | ### Removed
70 | * `doneOnOutput` and `done` are now `applyOnOutput` and `apply`. These new
71 | methods allow custom HttpAdapters to be used (so you can integrate more
72 | easily with frameworks), but if you supply no arguements the "global"
73 | HttpAdaper will be used (i.e. interact directly with PHPs `header()` and
74 | similar functions).
75 |
76 | * `addHeader` has been removed. You should add headers with `header()` or via
77 | your framework now.
78 |
79 | * `correctHeaderName` has been removed. Please ensure your header names are
80 | correct
81 |
82 | * PHP 5.3 is no longer supported.
83 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016-2017 Aidan Woods
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SecureHeaders [](https://travis-ci.org/aidantwoods/SecureHeaders) [](https://ci.appveyor.com/project/aidantwoods/SecureHeaders)
2 | A PHP class aiming to make the use of browser security features more accessible.
3 |
4 | For full documentation, please see the
5 | [Wiki](https://github.com/aidantwoods/SecureHeaders/wiki).
6 |
7 | A [demonstration](https://www.secureheaders.com/) with a sample configuration
8 | is also available.
9 |
10 | ## What is a 'secure header'?
11 | Secure headers, are a
12 | [set of headers](https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#tab=Headers)
13 | that configure browser security features. All of these headers can be used in
14 | any web application, and most can be deployed without any, or very minor code
15 | changes. However some of the most effective ones *do* require code changes –
16 | especially to implement well.
17 |
18 | ## Features
19 | * Add/remove and manage headers easily
20 | * Build a Content Security Policy, or combine multiple together
21 | * Content Security Policy analysis
22 | * Easy integeration with arbitrary frameworks (take a look at the HttpAdapter)
23 | * Protect incorrectly set cookies
24 | * Strict mode
25 | * Safe mode prevents accidental long-term self-DOS when using HSTS, or HPKP
26 | * Receive warnings about missing, or misconfigured security headers
27 |
28 | ## Methodology and Philosophy
29 | Error messages are often a great way for a program to tell the programmer that
30 | something is wrong. Whether it's calling a variable that's not yet been
31 | assigned, or causing a fatal error by exhausting the memory allocation limit.
32 |
33 | Both of these situations can usually be rectified very quickly by the
34 | programmer. The effort required to do so is greatly reduced because the program
35 | communicated exactly what the problem was, as soon as the programmer introduced
36 | the bug. SecureHeaders aims to apply this concept to browser security features.
37 |
38 | Utilising the error reporting level set within PHP configuration, SecureHeaders
39 | will generate `E_USER_WARNING` and `E_USER_NOTICE` level error messages to
40 | inform the programmer about either misconfigurations or lack of configuration.
41 |
42 | In addition to error reporting, SecureHeaders will make some **safe** proactive
43 | changes to certain headers, or even add new ones if they're missing.
44 |
45 | ## Installation
46 | ### Via Composer
47 | ```
48 | composer require aidantwoods/secureheaders
49 | ```
50 | ### Other
51 | Download [`SecureHeaders.phar`](https://github.com/aidantwoods/SecureHeaders/releases/latest), then
52 | ```php
53 | require_once('SecureHeaders.phar');
54 | ```
55 |
56 | ## Sounds good, but let's see some of the code...
57 | Here is a good implementation example
58 | ```php
59 | $headers = new SecureHeaders();
60 | $headers->hsts();
61 | $headers->csp('default', 'self');
62 | $headers->csp('script', 'https://my.cdn.org');
63 | $headers->apply();
64 | ```
65 |
66 | These few lines of code will take an application from a grade F, to a grade A
67 | on Scott Helme's https://securityheaders.io/
68 |
69 | ## Woah, that was easy! Tell me what it did...
70 | Let's break down the example above.
71 |
72 | 'Out of the box', SecureHeaders will already do quite a lot (by running the
73 | following code)
74 | ```php
75 | $headers = new SecureHeaders();
76 | $headers->apply();
77 | ```
78 |
79 | #### Automatic Headers and Errors
80 |
81 | With such code, the following will occur:
82 | * Warnings will be issued (`E_USER_WARNING`)
83 | > **Warning:** Missing security header: 'Strict-Transport-Security'
84 |
85 | > **Warning:** Missing security header: 'Content-Security-Policy'
86 |
87 | * The following headers will be automatically added
88 |
89 | ```
90 | Expect-CT: max-age=0
91 | Referrer-Policy: no-referrer
92 | Referrer-Policy: strict-origin-when-cross-origin
93 | X-Content-Type-Options:nosniff
94 | X-Frame-Options:Deny
95 | X-Permitted-Cross-Domain-Policies: none
96 | X-XSS-Protection:1; mode=block
97 | ```
98 | * The following header will also be removed (SecureHeaders will also attempt to
99 | remove the `Server` header, though it is unlikely this header will be under PHP
100 | jurisdiction)
101 |
102 | ```
103 | X-Powered-By
104 | ```
105 |
106 | #### Cookies
107 |
108 | Additionally, if any cookies have been set (at any time before `->apply()` is
109 | called) e.g.
110 | ```php
111 | setcookie('auth', 'supersecretauthenticationstring');
112 |
113 | $headers = new SecureHeaders();
114 | $headers->apply();
115 | ```
116 |
117 | Even though in the current PHP configuration, cookie flags `Secure` and
118 | `HTTPOnly` do **not** default to on, and despite the fact that
119 | PHP does not support the `SameSite` cookie attribute, the end result of the
120 | `Set-Cookie` header will be
121 | ```
122 | Set-Cookie:auth=supersecretauthenticationstring; Secure; HttpOnly; SameSite=Lax
123 | ```
124 |
125 | These flags were inserted by SecureHeaders because the cookie name contained
126 | the substring `auth`. Of course if that was a bad assumption, you can correct
127 | SecureHeaders' behaviour, or conversely you can tell SecureHeaders about some
128 | of your cookies that have less obvious names – but may need protecting in case
129 | of accidental missing flags.
130 |
131 | If you enable [`->strictMode()`](#Strict-Mode) then the `SameSite` setting will
132 | be set to strict (you can also upgrade this without using strict mode).
133 |
134 | #### Strict Mode
135 |
136 | Strict mode will enable settings that you **should** be using. It is highly
137 | advisable to adjust your application to work with strict mode enabled.
138 |
139 | When enabled, strict mode will:
140 | * Auto-enable HSTS with a 1 year duration, and the `includeSubDomains`
141 | and `preload` flags set. Note that this HSTS policy is made as a
142 | header proposal, and can thus be removed or modified.
143 |
144 | * The source keyword `'strict-dynamic'` will also be added to the first
145 | of the following directives that exist: `script-src`, `default-src`;
146 | only if that directive also contains a nonce or hash source value, and
147 | not otherwise.
148 |
149 | This will disable the source whitelist in `script-src` in CSP3
150 | compliant browsers. The use of whitelists in script-src is
151 | [considered not to be an ideal practice][1], because they are often
152 | trivial to bypass.
153 |
154 | [1]: https://research.google.com/pubs/pub45542.html "The Insecurity of
155 | Whitelists and the Future of Content Security Policy"
156 |
157 | Don't forget to [manually submit](https://hstspreload.appspot.com/)
158 | your domain to the HSTS preload list if you are using this option.
159 |
160 | * The default `SameSite` value injected into `->protectedCookie` will
161 | be changed from `SameSite=Lax` to `SameSite=Strict`.
162 | See documentation on `->auto` to enable/disable injection
163 | of `SameSite` and documentation on `->sameSiteCookies` for more on specific
164 | behaviour and to explicitly define this value manually, to override the
165 | default.
166 |
167 | * Auto-enable Expect-CT with a 1 year duration, and the `enforce` flag
168 | set. Note that this Expect-CT policy is made as a
169 | header proposal, and can thus be removed or modified.
170 |
171 | #### Back to the example
172 |
173 | Let's take a look at those other three lines, the first of which was
174 | ```php
175 | $headers->hsts();
176 | ```
177 | This enabled HSTS (Strict-Transport-Security) on the application for a duration
178 | of 1 year.
179 |
180 | *That sounds like something that might break things – I wouldn't want to
181 | accidentally enable that.*
182 |
183 | #### Safe Mode
184 |
185 | Okay, SecureHeaders has got you covered – use `$headers->safeMode();` to
186 | prevent headers being sent that will cause lasting effects.
187 |
188 | So for example, if the following code was run (safe mode can be called at any
189 | point before `->apply()` to be effective)
190 | ```php
191 | $headers->hsts();
192 | $headers->safeMode();
193 | ```
194 | HSTS would still be enabled (as asked), but would be limited to lasting 24
195 | hours.
196 |
197 | SecureHeaders would also generate the following notice
198 |
199 | > **Notice:** HSTS settings were overridden because Safe-Mode is enabled.
200 | > [Read about](https://scotthelme.co.uk/death-by-copy-paste/#hstsandpreloading)
201 | > some common mistakes when setting HSTS via copy/paste, and ensure you
202 | > [understand the details](https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet)
203 | > and possible side effects of this security feature before using it.
204 |
205 | *What if I set it via a method not related to SecureHeaders? Can SecureHeaders
206 | still enforce safe mode?*
207 |
208 | Yup! SecureHeaders will look at the names and values of headers independently
209 | of its own built in functions that can be used to generate them.
210 |
211 | For example, if I use PHPs built in header function to set HSTS for 1 year,
212 | for all subdomains, and indicate consent to preload that rule into major
213 | browsers, and then (before or after setting that header) enable safe-mode...
214 |
215 | ```php
216 | header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
217 | $headers->safeMode();
218 | ```
219 |
220 | The same above notice will be generated, max-age will be modified to 1 day, and
221 | the preload and includesubdomains flags will be removed.
222 |
223 | #### Content Security Policy
224 |
225 | The final two lines to cover from the initial example are as follows
226 | ```php
227 | $headers->csp('default', 'self');
228 | $headers->csp('script', 'https://my.cdn.org');
229 | ```
230 | These tell SecureHeaders that it should build a CSP (Content Security Policy)
231 | that allows default assets to be loaded from the current domain (self), and
232 | that scripts should be allowed from https://my.cdn.org.
233 |
234 | Note that if we had said http://my.cdn.org instead, then the following would
235 | have been generated
236 |
237 | > **Warning:** Content Security Policy contains the insecure protocol HTTP in a
238 | > source value **http://my.cdn.org**; this can allow anyone to insert elements
239 | > covered by the **script-src** directive into the page.
240 |
241 | Similarly, if wildcards such as `'unsafe-inline'`, `https:`, or `*` are
242 | included – SecureHeaders will generate warnings to highlight these CSP bypasses.
243 |
244 | Note that the `->csp` function is very diverse in what it will accept, to see
245 | some more on that take a look at [Using CSP](#using-csp)
246 |
247 | ## Sending the headers
248 | In order to apply anything added through SecureHeaders, you'll need to call
249 | `->apply()`. By design, SecureHeaders doesn't have a construct function – so
250 | everything up until `->apply()` is called is just configuration. However, if you
251 | don't want to have to remember to call this function, you can call
252 | `->applyOnOutput()` instead, at any time. This will utilise PHP's `ob_start()`
253 | function to start output buffering. This lets SecureHeaders attatch itself to
254 | the first instance of any piece of code that generates output – and prior to
255 | actually sending that output to the user, make sure all headers are sent, by
256 | calling `->apply()` for you.
257 |
258 | Because SecureHeaders doesn't have a construct function, you can easily
259 | implement your own, via a simple class extension, e.g.
260 | ```php
261 | class CustomSecureHeaders extends SecureHeaders{
262 | public function __construct()
263 | {
264 | $this->applyOnOutput();
265 | $this->hsts();
266 | $this->csp('default', 'self');
267 | $this->csp('script', 'https://my.cdn.org');
268 | }
269 | }
270 | ```
271 |
272 | The above would implement the example discussed above, and would automatically
273 | apply to any page that ran just one line of code
274 | ```php
275 | $headers = new CustomSecureHeaders();
276 | ```
277 |
278 | Of course, pages could add additional configuration too, and headers would only
279 | be applied when the page started generating output.
280 |
281 |
282 | ## Another Example
283 |
284 | If the following CSP is created (note this probably isn't the best way to
285 | define a CSP of this size, see the array syntax that is available in the
286 | section on [Using CSP](#using-csp))
287 |
288 | ```php
289 | $headers->csp('default', '*');
290 | $headers->csp('script', 'unsafe-inline');
291 | $headers->csp('script', 'http://insecure.cdn.org');
292 | $headers->csp('style', 'https:');
293 | $headers->csp('style', '*');
294 | $headers->csp('report', 'https://valid-enforced-url.org');
295 | $headers->cspro('report', 'whatisthis');
296 | ```
297 |
298 | ```
299 | Content-Security-Policy:default-src *; script-src 'unsafe-inline'
300 | http://insecure.cdn.org; style-src https: *; report-uri
301 | https://valid-enforced-url.org;
302 |
303 | Content-Security-Policy-Report-Only:report-uri whatisthis;
304 | ```
305 |
306 | The following messages will be issued with regard to CSP:
307 | (level `E_USER_WARNING` and level `E_USER_NOTICE`)
308 |
309 | * The default-src directive contains a wildcard (so is a CSP bypass)
310 |
311 | > **Warning:** Content Security Policy contains a wildcard __*__ as a source
312 | > value in **default-src**; this can allow anyone to insert elements covered
313 | > by the **default-src** directive into the page.
314 | * The script-src directive contains an a flag that allows inline script (so is
315 | a CSP bypass)
316 |
317 | > **Warning:** Content Security Policy contains the **'unsafe-inline'**
318 | > keyword in **script-src**, which prevents CSP protecting against the
319 | > injection of arbitrary code into the page.
320 | * The script-src directive contains an insecure resource as a source value
321 | (HTTP responses can be trivially spoofed – spoofing allows a bypass)
322 |
323 | > **Warning:** Content Security Policy contains the insecure protocol HTTP in
324 | > a source value **http://insecure.cdn.org**; this can allow anyone to insert
325 | > elements covered by the **script-src** directive into the page.
326 | * The style-src directive contains two wildcards (so is a CSP bypass) – both
327 | wildcards are listed
328 |
329 | > **Warning:** Content Security Policy contains the following wildcards
330 | > **https:**, __*__ as a source value in **style-src**; this can allow
331 | > anyone to insert elements covered by the style-src directive into the page.
332 | * The report only header was sent, but no/an invalid reporting address was
333 | given – preventing the report only header from doing anything useful in the wild
334 |
335 | > **Notice:** Content Security Policy Report Only header was sent, but an
336 | > invalid, or no reporting address was given. This header will not enforce
337 | > violations, and with no reporting address specified, the browser can only
338 | > report them locally in its console. Consider adding a reporting address to
339 | > make full use of this header.
340 |
341 |
342 | ## Using CSP
343 |
344 | If you're new to Content-Security-Policy then running your proposed policy
345 | through [Google's CSP Evaluator](https://csp-evaluator.withgoogle.com/) may be
346 | a good idea.
347 |
348 | Let's take a look at a few ways of declaring the following CSP (or parts of
349 | it). Newlines and indentation added here for readability
350 | ```
351 | Content-Security-Policy:
352 | default-src 'self';
353 | script-src 'self' https://my.cdn.org https://scripts.cdn.net https://other.cdn.com;
354 | img-src https://images.cdn.xyz;
355 | style-src https://amazingstylesheets.cdn.pizza;
356 | base-uri 'self';
357 | form-action 'none';
358 | upgrade-insecure-requests;
359 | block-all-mixed-content;
360 | ```
361 | #### CSP as an array
362 | ```php
363 | $myCSP = array(
364 | 'default-src' => [
365 | "'self'"
366 | ],
367 | 'script-src' => [
368 | 'self',
369 | 'https://my.cdn.org',
370 | 'https://scripts.cdn.net',
371 | 'https://other.cdn.com'
372 | ],
373 | 'img-src' => ['https://images.cdn.xyz'],
374 | 'style-src' => 'https://amazingstylesheets.cdn.pizza',
375 | 'base' => 'self',
376 | 'form' => 'none',
377 | 'upgrade-insecure-requests' => null,
378 | 'block-all-mixed-content'
379 | );
380 |
381 | $headers->csp($myCSP);
382 | ```
383 |
384 | In the above, we've specified the policy using an array in the way it makes the
385 | most sense (bar some slight variation to demonstrate supported syntax).
386 | We then passed our policy array to the `csp` function.
387 |
388 | Within the array, take a look at `default-src`. This is the full directive name
389 | (the key of the array), and its source list is specified as an array containing
390 | source values. In this case, the directive only has one source value, `'self'`,
391 | which is spelled out in full (note the single quotes within the string).
392 |
393 | In this case, we've actually written a lot more than necessary – see the
394 | directive `base` for comparison. The actual CSP directive here is `base-uri`,
395 | but `base` is a supported shorthand by SecureHeaders. Secondly, we've omitted
396 | the array syntax from the descending source list entirely – we only wanted to
397 | declare one valid source, so SecureHeaders supports foregoing the array
398 | structure if its not useful. Additionally, we've made use of a shorthand within
399 | the source value too – omitting the single quotes from the string's value (i.e.
400 | `self` is a shorthand for `'self'`).
401 |
402 | There are two CSP 'flags' included also in this policy, namely
403 | `upgrade-insecure-requests` and `block-all-mixed-content`. These do not hold
404 | any source values (and would not be valid in CSP if they did). You can specify
405 | these by either giving a source value of `null` (either as above, or an array
406 | containing only null as a source), or forgoing any mention of decedents
407 | entirely (as shown in `block-all-mixed-content`, which is written as-is).
408 | Once a flag has been set, no sources may be added. Similarly once a directive
409 | has been set, it may not become a flag. (This to prevent accidental loss of the
410 | entire source list).
411 |
412 | The `csp` function also supports combining these CSP arrays, so the following
413 | would combine the csp defined in `$myCSP`, and `$myOtherCSP`. You can combine
414 | as many csp arrays as you like by adding additional arguments.
415 |
416 | ```php
417 | $headers->csp($myCSP, $myOtherCSP);
418 | ```
419 |
420 | #### CSP as ordered pairs
421 | Using the same `csp` function as above, you can add sources to directives as
422 | follows
423 | ```php
424 | $headers->csp('default', 'self');
425 | $headers->csp('script', 'self');
426 | $headers->csp('script', 'https://my.cdn.org');
427 | ```
428 | or if you prefer to do this all in one line
429 | ```php
430 | $headers->csp('default', 'self', 'script', 'self', 'script', 'https://my.cdn.org');
431 | ```
432 |
433 | Note that directives and sources are specified as ordered pairs here.
434 |
435 | If you wanted to add a CSP flag in this way, simply use one of the following.
436 | ```php
437 | $headers->csp('upgrade-insecure-requests');
438 | $headers->csp('block-all-mixed-content', null);
439 | ```
440 | Note that the second way is necessary if embedded in a list of ordered pairs –
441 | otherwise SecureHeaders can't tell what is a directive name or a source value.
442 | e.g. this would set `block-all-mixed-content` as a CSP flag, and
443 | `https://my.cdn.org` as a script-src source value.
444 | ```php
445 | $headers->csp('block-all-mixed-content', null, 'script', 'https://my.cdn.org');
446 | ```
447 |
448 | **However**, the `csp` function also supports mixing these ordered pairs with
449 | the array structure, and a string without a source at the end of the argument
450 | list will also be treated as a flag. You could,
451 | *in perhaps an abuse of notation*, use the following to set two CSP flags and
452 | the policy contained in the `$csp` array structure.
453 |
454 | ```php
455 | $headers->csp('block-all-mixed-content', $csp, 'upgrade-insecure-requests');
456 | ```
457 |
458 | #### CSP as, uhh..
459 | The CSP function aims to be as tolerant as possible, a CSP should be able to be
460 | communicated in whatever way is easiest to you.
461 |
462 | That said, please use responsibly – the following is quite hard to read
463 |
464 | ```php
465 | $myCSP = array(
466 | 'default-src' => [
467 | "'self'"
468 | ],
469 | 'script-src' => [
470 | "'self'",
471 | 'https://my.cdn.org'
472 | ],
473 | 'script' => [
474 | 'https://scripts.cdn.net'
475 | ],
476 | );
477 |
478 | $myotherCSP = array(
479 | 'base' => 'self'
480 | );
481 |
482 | $whoopsIforgotThisCSP = array(
483 | 'form' => 'none'
484 | );
485 |
486 | $headers->csp(
487 | $myCSP, 'script', 'https://other.cdn.com',
488 | ['block-all-mixed-content'], 'img',
489 | 'https://images.cdn.xyz', $myotherCSP
490 | );
491 | $headers->csp(
492 | 'style', 'https://amazingstylesheets.cdn.pizza',
493 | $whoopsIforgotThisCSP, 'upgrade-insecure-requests'
494 | );
495 | ```
496 |
497 | #### Behaviour when a CSP header has already been set
498 | ```php
499 | header("Content-Security-Policy: default-src 'self'; script-src https://cdn.org 'self'");
500 | $headers->csp('script', 'https://another.domain.example.com');
501 | ```
502 |
503 | The above code will perform a merge the set CSP header, and the additional
504 | `script-src` value set in the final line. Producing the following merged
505 | CSP header
506 | ```
507 | Content-Security-Policy: script-src https://another.domain.example.com https://cdn.org 'self'; default-src 'self'
508 | ```
509 |
510 | #### Content-Security-Policy-Report-Only
511 | All of the above is applicable to report only policies in exactly the same way.
512 | To tell SecureHeaders that you're creating a report only policy, simply use
513 | `->cspro` in place of `->csp`.
514 |
515 | As an alternate method, you can also include the boolean `true`, or a non zero
516 | integer (loosely compares to `true`) in the regular `->csp` function's argument
517 | list. The boolean `false` or the integer zero will signify enforced CSP
518 | (already the default). The left-most of these booleans or intgers will be taken
519 | as the mode. So to force enforced CSP (in-case you are unsure of the eventual
520 | variable types in the CSP argument list), use
521 | `->csp(false, arg1[, arg2[, ...]])` etc... or use zero in place of `false`.
522 | Similarly, to force report-only (in-case you are unsure of the eventual
523 | variable types in the CSP argument list) you can use either
524 | `->cspro(arg1[, arg2[, ...]])` or `->csp(true, arg1[, arg2[, ...]])`.
525 |
526 | Note that while `->csp` supports having its mode changed to report-only,
527 | `->cspro` does not (since is an alias for `->csp` with report-only forced on).
528 | `->csp` and `->cspro` are identical in their interpretation of the various
529 | structures a Content-Security-Policy can be communicated in.
530 |
531 | ## More on Usage
532 | For full documentation, please see the
533 | [Wiki](https://github.com/aidantwoods/SecureHeaders/wiki)
534 |
535 | ## Versioning
536 | The SecureHeaders project will follow [Semantic Versioning 2], with
537 | the following declared public API:
538 |
539 | Any method baring the [`@api`](https://phpdoc.org/docs/latest/references/phpdoc/tags/api.html)
540 | phpdoc tag.
541 |
542 | Roughtly speaking
543 |
544 | * Every public method in `Aidantwoods\SecureHeaders\SecureHeaders` (except `Aidantwoods\SecureHeaders\SecureHeaders::returnBuffer`)
545 | * Every public method in `Aidantwoods\SecureHeaders\Http`
546 | * Every public method in `Aidantwoods\SecureHeaders\HeaderBag`
547 |
548 | This allows the main SecureHeaders class to be used as expected by [semver], and
549 | also the HttpAdapter interface/implementation (for integration with anything)
550 | to be used as expected by [semver].
551 |
552 | All other methods and properties are therefore non-public for the purposes of
553 | [semver]. That means that, e.g. methods with public visibility that are not in
554 | the above scope are subject to change in a backwards incompatible way, without
555 | a major version bump.
556 |
557 | [Semantic Versioning 2]: http://semver.org/
558 | [semver]: http://semver.org/
559 |
560 | ## ChangeLog
561 | The SecureHeaders project will follow
562 | [Keep a CHANGELOG](http://keepachangelog.com/) principles
563 |
564 | Check out the `ChangeLogs/` folder, to see these.
565 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aidantwoods/secureheaders",
3 | "description": "A PHP class aiming to make the use of browser security features more accessible.",
4 | "keywords": [
5 | "content-security-policy",
6 | "cookie",
7 | "csp",
8 | "headers",
9 | "hsts",
10 | "secure",
11 | "secureheaders"
12 | ],
13 | "homepage": "https://github.com/aidantwoods/SecureHeaders/wiki",
14 | "support": {
15 | "issues": "https://github.com/aidantwoods/secureheaders/issues",
16 | "source": "https://github.com/aidantwoods/secureheaders"
17 | },
18 | "type": "library",
19 | "license": "MIT",
20 | "authors": [
21 | {
22 | "name": "Aidan Woods",
23 | "email": "aidantwoods@gmail.com",
24 | "homepage": "https://aidanwoods.com"
25 | }
26 | ],
27 | "require": {
28 | "php": ">=5.4.0"
29 | },
30 | "autoload": {
31 | "psr-4": {
32 | "Aidantwoods\\SecureHeaders\\": "src/"
33 | }
34 | },
35 | "repositories": [
36 | {
37 | "type": "vcs",
38 | "url": "https://github.com/aidantwoods/PHP-CS-Fixer"
39 | }
40 | ],
41 | "suggest": {
42 | "psr/http-message": "In case you want to use the PSR-7 adapter",
43 | "aidantwoods/markdownphpdocs": "Install this on its own and add it to your path, to auto-generate documentation if contributing to this repo."
44 | },
45 | "require-dev": {
46 | "friendsofphp/php-cs-fixer": "dev-feature/braces/position_after_return_type_hint",
47 | "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4",
48 | "psr/http-message": "^1.0",
49 | "zendframework/zend-diactoros": "^1.0"
50 | },
51 | "autoload-dev": {
52 | "psr-4": {
53 | "Aidantwoods\\SecureHeaders\\Tests\\": "tests/"
54 | }
55 | },
56 | "config": {
57 | "sort-packages": true
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/demo/CustomSecureHeaders.php:
--------------------------------------------------------------------------------
1 | apply() on first byte of output
10 | $this->applyOnOutput();
11 |
12 | # content headers
13 | header('Content-type: text/html; charset=utf-8');
14 |
15 | # Custom function added in this extenstion:
16 | # redirect to www subdomain if not on localhost
17 | $this->www_if_not_localhost();
18 |
19 | # add a csp policy, as specified in $base, defined below
20 | $this->csp($this->base);
21 |
22 | $this->cspNonce('style');
23 | $this->cspNonce('script');
24 |
25 | # whitelist a css snippet in the style-src directive
26 | $style = 'body {background: black;}';
27 | $this->cspHash('style', $style);
28 |
29 | # add csp reporting
30 | $this->csp(
31 | 'report', 'https://report-uri.example.com/csp',
32 | 'script', 'http://my.cdn.org'
33 | );
34 |
35 | # add some cookies
36 | setcookie('auth1', 'not a secret');
37 | setcookie('sId', 'secret');
38 | $this->protectedCookie('auth', self::COOKIE_SUBSTR | self::COOKIE_REMOVE);
39 |
40 | setcookie('sess1', 'secret');
41 | setcookie('notasessioncookie', 'not a secret');
42 | $this->protectedCookie('sess', self::COOKIE_SUBSTR | self::COOKIE_REMOVE);
43 | $this->protectedCookie('sess1', self::COOKIE_NAME);
44 |
45 | setcookie('preference', 'not a secret');
46 | setcookie('another-preference', 'not a secret', 10, '/', null, true, false);
47 |
48 | # add a hpkp policy
49 | $this->hpkp(
50 | [
51 | 'pin1',
52 | ['pin2', 'sha256'],
53 | ['sha256', 'pin3'],
54 | ['pin4']
55 | ],
56 | 1500,
57 | 1
58 | );
59 |
60 | # use regular PHP function to add strict transport security
61 | header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
62 |
63 | # enable safe-mode, which should auto-modify the above header
64 | # safe-mode will generate an error of level E_USER_NOTICE if it has to modify any headers
65 | $this->safeMode();
66 |
67 | # uncomment the next line to allow HSTS in safe mode
68 | // $this->safeModeException('Strict-Transport-Security');
69 | }
70 |
71 | public function www_if_not_localhost()
72 | {
73 | if ($_SERVER['SERVER_NAME'] !== 'localhost' and substr($_SERVER['HTTP_HOST'], 0, 4) !== 'www.')
74 | {
75 | header('HTTP/1.1 301 Moved Permanently');
76 | header('Location: https://www.'.$_SERVER['SERVER_NAME'].$_SERVER['REQUEST_URI']);
77 | }
78 | }
79 |
80 | private $base = [
81 | "default-src" => ["'self'"],
82 | "script-src" => [
83 | "'self'",
84 | "https://www.google-analytics.com/"
85 | ],
86 | "style-src" => [
87 | "'self'",
88 | "https://fonts.googleapis.com/",
89 | "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/"
90 | ],
91 | "img-src" => [
92 | "'self'",
93 | "https://www.google-analytics.com/",
94 | ],
95 | "font-src" => [
96 | "'self'",
97 | "https://fonts.googleapis.com/",
98 | "https://fonts.gstatic.com/",
99 | "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/"
100 | ],
101 | "child-src" => [
102 | "'self'"
103 | ],
104 | "frame-src" => [
105 | "'self'"
106 | ],
107 | "base-uri" => ["'self'"],
108 | "connect-src" => [
109 | "'self'",
110 | "https://www.google-analytics.com/r/collect"
111 | ],
112 | "form-action" => [
113 | "'self'"
114 | ],
115 | "frame-ancestors" => ["'none'"],
116 | "object-src" => ["'none'"],
117 | 'block-all-mixed-content' => [null]
118 | ];
119 | }
120 |
--------------------------------------------------------------------------------
/src/Error.php:
--------------------------------------------------------------------------------
1 | [$message], 'int' => [$level]]);
21 |
22 | $message = preg_replace('/[\\\]\n\s*/', '', $message);
23 | $this->message = preg_replace('/\s+/', ' ', $message);
24 |
25 | $this->level = $level;
26 | }
27 |
28 | /**
29 | * Get the Error's level
30 | *
31 | * @return int
32 | */
33 | public function getLevel()
34 | {
35 | return $this->level;
36 | }
37 |
38 | /**
39 | * Get the Error's message
40 | *
41 | * @return string
42 | */
43 | public function getMessage()
44 | {
45 | return $this->message;
46 | }
47 |
48 | /**
49 | * Get the Error's message
50 | *
51 | * @return string
52 | */
53 | public function __toString()
54 | {
55 | return $this->getMessage();
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/ExposesErrors.php:
--------------------------------------------------------------------------------
1 | $headers
17 | */
18 | public function __construct(array $headers = [])
19 | {
20 | # Send all headers through `add` to make sure they are properly
21 | # lower-cased
22 | foreach ($headers as $name => $value)
23 | {
24 | $this->add($name, $value);
25 | }
26 | }
27 |
28 | /**
29 | * Create a HeaderBag from an array of header lines, $lines.
30 | *
31 | * @api
32 | *
33 | * @param string[] $lines
34 | * @return static
35 | */
36 | public static function fromHeaderLines(array $lines)
37 | {
38 | $bag = new static;
39 |
40 | foreach ($lines as $line)
41 | {
42 | preg_match('/^([^:]++)(?|(?:[:][ ]?+)(.*+)|())/', $line, $matches);
43 | array_shift($matches);
44 |
45 | list($name, $value) = $matches;
46 |
47 | $bag->add($name, $value);
48 | }
49 |
50 | return $bag;
51 | }
52 |
53 | /**
54 | * Determine whether the HeaderBag contains a header with $name,
55 | * case-insensitively.
56 | *
57 | * @api
58 | *
59 | * @param string $name
60 | * @return bool
61 | */
62 | public function has($name)
63 | {
64 | Types::assert(['string' => [$name]]);
65 |
66 | return array_key_exists(strtolower($name), $this->headers);
67 | }
68 |
69 | /**
70 | * Add a header with $name and value $value
71 | *
72 | * @api
73 | *
74 | * @param string $name
75 | * @param string $value
76 | * @return void
77 | */
78 | public function add($name, $value = '')
79 | {
80 | Types::assert(['string' => [$name, $value]]);
81 |
82 | $key = strtolower($name);
83 | if ( ! array_key_exists($key, $this->headers))
84 | {
85 | $this->headers[$key] = [];
86 | }
87 |
88 | $this->headers[$key][] = HeaderFactory::build($name, $value);
89 | }
90 |
91 | /**
92 | * Add (in replace mode) a header with $name and value $value
93 | *
94 | * @api
95 | *
96 | * @param string $name
97 | * @param string $value
98 | * @return void
99 | */
100 | public function replace($name, $value = '')
101 | {
102 | Types::assert(['string' => [$name, $value]]);
103 |
104 | $header = HeaderFactory::build($name, $value);
105 | $this->headers[strtolower($name)] = [$header];
106 | }
107 |
108 | /**
109 | * Remove header(s) with $name
110 | *
111 | * @api
112 | *
113 | * @param string $name
114 | * @return void
115 | */
116 | public function remove($name)
117 | {
118 | Types::assert(['string' => [$name]]);
119 |
120 | unset($this->headers[strtolower($name)]);
121 | }
122 |
123 | /**
124 | * Remove all headers from the HeaderBag.
125 | *
126 | * @api
127 | *
128 | * @return void
129 | */
130 | public function removeAll()
131 | {
132 | $this->headers = [];
133 | }
134 |
135 | /**
136 | * Get all Headers from the HeaderBag
137 | *
138 | * @api
139 | *
140 | * @return Header[]
141 | */
142 | public function get()
143 | {
144 | return array_reduce(
145 | $this->headers,
146 | function ($all, $item)
147 | {
148 | return array_merge($all, $item);
149 | },
150 | []
151 | );
152 | }
153 |
154 | /**
155 | * Get Headers from the HeaderBag with name, $name
156 | *
157 | * @api
158 | *
159 | * @param string $name
160 | * @return Header[]
161 | */
162 | public function getByName($name)
163 | {
164 | Types::assert(['string' => [$name]]);
165 |
166 | $name = strtolower($name);
167 |
168 | if ( ! array_key_exists($name, $this->headers))
169 | {
170 | return [];
171 | }
172 |
173 | return $this->headers[$name];
174 | }
175 |
176 | /**
177 | * Let a header named $name be $header.
178 | * Apply $callable($header) to every header named $name.
179 | *
180 | * @api
181 | *
182 | * @param string $name
183 | * @param callable $callable
184 | * @return void
185 | */
186 | public function forEachNamed($name, callable $callable)
187 | {
188 | Types::assert(['string' => [$name]]);
189 |
190 | $name = strtolower($name);
191 |
192 | if (isset($this->headers[$name]))
193 | {
194 | foreach ($this->headers[$name] as $header)
195 | {
196 | $callable($header);
197 | }
198 | }
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/src/HeaderFactory.php:
--------------------------------------------------------------------------------
1 | [
12 | 'content-security-policy',
13 | 'content-security-policy-report-only',
14 | 'x-content-security-policy',
15 | 'x-content-security-policy-report-only'
16 | ]
17 | ];
18 |
19 | /**
20 | * Create a Header with name $name, and value $value
21 | *
22 | * @param string $name
23 | * @param string $value
24 | */
25 | public static function build($name, $value = '')
26 | {
27 | Types::assert(['string' => [$name, $value]]);
28 |
29 | $namespace = __NAMESPACE__.'\\Headers';
30 |
31 | foreach (self::$memberClasses as $class => $headerNames)
32 | {
33 | $class = "$namespace\\$class";
34 |
35 | if (in_array(strtolower($name), $headerNames, true))
36 | {
37 | return new $class($name, $value);
38 | }
39 | }
40 |
41 | return new RegularHeader($name, $value);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Headers/AbstractHeader.php:
--------------------------------------------------------------------------------
1 | [$name, $value]]);
25 |
26 | $this->name = $name;
27 | $this->value = $value;
28 |
29 | $this->parseAttributes();
30 | }
31 |
32 | /**
33 | * {@inheritDoc}
34 | */
35 | public function getName()
36 | {
37 | return strtolower($this->name);
38 | }
39 |
40 | /**
41 | * {@inheritDoc}
42 | */
43 | public function getFriendlyName()
44 | {
45 | $friendlyHeader = str_replace('-', ' ', $this->getName());
46 | return ucwords($friendlyHeader);
47 | }
48 |
49 | /**
50 | * {@inheritDoc}
51 | */
52 | public function is($name)
53 | {
54 | Types::assert(['string' => [$name]]);
55 |
56 | return strtolower($name) === strtolower($this->name);
57 | }
58 |
59 | /**
60 | * {@inheritDoc}
61 | */
62 | public function getValue()
63 | {
64 | return $this->value;
65 | }
66 |
67 | /**
68 | * {@inheritDoc}
69 | */
70 | public function setValue($newValue)
71 | {
72 | $this->value = $newValue;
73 |
74 | $this->parseAttributes();
75 | }
76 |
77 | /**
78 | * {@inheritDoc}
79 | */
80 | public function getFirstAttributeName()
81 | {
82 | reset($this->attributes);
83 |
84 | return key($this->attributes);
85 | }
86 |
87 | /**
88 | * {@inheritDoc}
89 | */
90 | public function getAttributeValue($name)
91 | {
92 | Types::assert(['string' => [$name]]);
93 |
94 | if ( ! $this->hasAttribute($name))
95 | {
96 | throw new InvalidArgumentException(
97 | "Attribute '$name' was not found"
98 | );
99 | }
100 |
101 | return $this->attributes[strtolower($name)][0]['value'];
102 | }
103 |
104 | /**
105 | * {@inheritDoc}
106 | */
107 | public function hasAttribute($name)
108 | {
109 | Types::assert(['string' => [$name]]);
110 |
111 | $name = strtolower($name);
112 |
113 | return array_key_exists($name, $this->attributes);
114 | }
115 |
116 | /**
117 | * {@inheritDoc}
118 | */
119 | public function removeAttribute($name)
120 | {
121 | Types::assert(['string' => [$name]]);
122 |
123 | $name = strtolower($name);
124 | unset($this->attributes[$name]);
125 |
126 | $this->writeAttributesToValue();
127 | }
128 |
129 | /**
130 | * {@inheritDoc}
131 | */
132 | public function ensureAttributeMaximum($name, $maxValue)
133 | {
134 | Types::assert(['string' => [$name], 'int' => [$maxValue]]);
135 |
136 | if (isset($this->attributes[$name]))
137 | {
138 | foreach ($this->attributes[$name] as &$attribute)
139 | {
140 | if (intval($attribute['value']) > $maxValue)
141 | {
142 | $attribute['value'] = $maxValue;
143 | }
144 | }
145 |
146 | $this->writeAttributesToValue();
147 | }
148 | }
149 |
150 | /**
151 | * {@inheritDoc}
152 | */
153 | public function setAttribute($name, $value = true)
154 | {
155 | Types::assert(['string' => [$name], 'int|bool|string' => [$value]]);
156 |
157 | $key = strtolower($name);
158 |
159 | $this->attributes[$key] = [
160 | [
161 | 'name' => $name,
162 | 'value' => $value
163 | ]
164 | ];
165 |
166 | if ($value === false)
167 | {
168 | unset($this->attributes[$key]);
169 | }
170 |
171 | $this->writeAttributesToValue();
172 | }
173 |
174 | /**
175 | * {@inheritDoc}
176 | */
177 | public function forEachAttribute(callable $callable)
178 | {
179 | foreach ($this->attributes as $attributes)
180 | {
181 | foreach ($attributes as $attribute)
182 | {
183 | $callable($attribute['name'], $attribute['value']);
184 | }
185 | }
186 | }
187 |
188 | /**
189 | * {@inheritDoc}
190 | */
191 | public function __toString()
192 | {
193 | return $this->name . ':' .($this->value === '' ? '' : ' ' . $this->value);
194 | }
195 |
196 | /**
197 | * Parse and store attributes from the internal header value
198 | *
199 | * @return void
200 | */
201 | protected function parseAttributes()
202 | {
203 | $parts = explode('; ', $this->value);
204 |
205 | $this->attributes = [];
206 |
207 | foreach ($parts as $part)
208 | {
209 | $attrParts = explode('=', $part, 2);
210 |
211 | $type = strtolower($attrParts[0]);
212 |
213 | if ( ! isset($this->attributes[$type]))
214 | {
215 | $this->attributes[$type] = [];
216 | }
217 |
218 | $this->attributes[$type][] = [
219 | 'name' => $attrParts[0],
220 | 'value' => isset($attrParts[1]) ? $attrParts[1] : true
221 | ];
222 | }
223 | }
224 |
225 | /**
226 | * Write internal attributes to the internal header value
227 | *
228 | * @return void
229 | */
230 | protected function writeAttributesToValue()
231 | {
232 | $attributeStrings = [];
233 |
234 | foreach ($this->attributes as $attributes)
235 | {
236 | foreach ($attributes as $attrInfo)
237 | {
238 | $key = $attrInfo['name'];
239 | $value = $attrInfo['value'];
240 |
241 | if ($value === true)
242 | {
243 | $string = $key;
244 | }
245 | elseif ($value === false)
246 | {
247 | continue;
248 | }
249 | else
250 | {
251 | $string = "$key=$value";
252 | }
253 |
254 | $attributeStrings[] = $string;
255 | }
256 | }
257 |
258 | $this->value = implode('; ', $attributeStrings);
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/src/Headers/CSPHeader.php:
--------------------------------------------------------------------------------
1 | attributes = [];
17 |
18 | $policy = CompileCSP::deconstructCSP($this->value);
19 |
20 | foreach ($policy as $directive => $sources)
21 | {
22 | if ( ! isset($this->attributes[$directive]))
23 | {
24 | $this->attributes[$directive] = [];
25 | }
26 |
27 | $this->attributes[$directive][] = [
28 | 'name' => $directive,
29 | 'value' => $sources === true ?: implode(' ', $sources)
30 | ];
31 | }
32 | }
33 |
34 | /**
35 | * {@inheritDoc}
36 | */
37 | protected function writeAttributesToValue()
38 | {
39 | $policies = [];
40 |
41 | foreach ($this->attributes as $attributes)
42 | {
43 | foreach ($attributes as $attrInfo)
44 | {
45 | $directive = $attrInfo['name'];
46 | $value = $attrInfo['value'];
47 |
48 | if ($value === true)
49 | {
50 | $string = $directive;
51 | }
52 | elseif ( ! is_string($value) or trim($value) === '')
53 | {
54 | continue;
55 | }
56 | else
57 | {
58 | $string = "$directive $value";
59 | }
60 |
61 | $policy = CompileCSP::deconstructCSP($string);
62 |
63 | $policies[] = $policy;
64 | }
65 | }
66 |
67 | $policy = CompileCSP::mergeCSPList($policies);
68 |
69 | $this->value = CompileCSP::compile($policy);
70 | }
71 |
72 | /**
73 | * {@inheritDoc}
74 | */
75 | public function setAttribute($name, $value = true)
76 | {
77 | Types::assert(['string' => [$name], 'int|bool|string' => [$value]]);
78 |
79 | $key = strtolower($name);
80 |
81 | if ( ! isset($this->attributes[$key]))
82 | {
83 | $this->attributes[$key] = [];
84 | }
85 |
86 | $this->attributes[$key][] = [
87 | 'name' => $name,
88 | 'value' => $value
89 | ];
90 |
91 | $this->writeAttributesToValue();
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/Headers/RegularHeader.php:
--------------------------------------------------------------------------------
1 | get() as $header)
22 | {
23 | header(
24 | (string) $header,
25 | false
26 | );
27 | }
28 | }
29 |
30 | /**
31 | * Retrieve the current list of already-sent (or planned-to-be-sent) headers
32 | *
33 | * @api
34 | *
35 | * @return HeaderBag
36 | */
37 | public function getHeaders()
38 | {
39 | return HeaderBag::fromHeaderLines(
40 | headers_list()
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Http/HttpAdapter.php:
--------------------------------------------------------------------------------
1 | response = $response;
21 | }
22 |
23 | /**
24 | * Send the given headers, overwriting all previously send headers
25 | *
26 | * @api
27 | *
28 | * @param HeaderBag $headers
29 | * @return void
30 | */
31 | public function sendHeaders(HeaderBag $headers)
32 | {
33 | # First, remove all headers on the response object
34 | $headersToRemove = $this->response->getHeaders();
35 | foreach ($headersToRemove as $name => $headerLines)
36 | {
37 | $this->response = $this->response->withoutHeader($name);
38 | }
39 |
40 | # And then, reset all headers from the HeaderBag instance
41 | foreach ($headers->get() as $header)
42 | {
43 | $this->response = $this->response->withAddedHeader(
44 | $header->getName(),
45 | $header->getValue()
46 | );
47 | }
48 | }
49 |
50 | /**
51 | * Retrieve the current list of already-sent (or planned-to-be-sent) headers
52 | *
53 | * @api
54 | *
55 | * @return HeaderBag
56 | */
57 | public function getHeaders()
58 | {
59 | $headerLines = [];
60 | foreach ($this->response->getHeaders() as $name => $lines)
61 | {
62 | foreach ($lines as $line)
63 | {
64 | $headerLines[] = "$name: $line";
65 | }
66 | }
67 |
68 | return HeaderBag::fromHeaderLines($headerLines);
69 | }
70 |
71 | /**
72 | * Retrieve the new PSR-7 response object, with security headers applied
73 | *
74 | * @api
75 | *
76 | * @return ResponseInterface
77 | */
78 | public function getFinalResponse()
79 | {
80 | return $this->response;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Http/StringHttpAdapter.php:
--------------------------------------------------------------------------------
1 | headers = $initialHeaders;
23 | }
24 |
25 | /**
26 | * Send the given headers, overwriting all previously send headers
27 | *
28 | * @api
29 | *
30 | * @param HeaderBag $headers
31 | * @return void
32 | */
33 | public function sendHeaders(HeaderBag $headers)
34 | {
35 | $this->headers = $headers->get();
36 | }
37 |
38 | /**
39 | * Retrieve the current list of already-sent (or planned-to-be-sent) headers
40 | *
41 | * @api
42 | *
43 | * @return HeaderBag
44 | */
45 | public function getHeaders()
46 | {
47 | return HeaderBag::fromHeaderLines($this->headers);
48 | }
49 |
50 | /**
51 | * @api
52 | *
53 | * @return string
54 | */
55 | public function getSentHeaders()
56 | {
57 | $compiledHeaders = [];
58 |
59 | foreach ($this->headers as $header)
60 | {
61 | $compiledHeaders[] = (string) $header;
62 | }
63 |
64 | return implode("\n", $compiledHeaders);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Operation.php:
--------------------------------------------------------------------------------
1 | [$name], 'string|array' => [$value]]);
25 |
26 | $this->name = $name;
27 |
28 | if ( ! is_array($value))
29 | {
30 | $value = [$value];
31 | }
32 |
33 | $this->value = $value;
34 | }
35 |
36 | /**
37 | * Transform the given set of headers
38 | *
39 | * @param HeaderBag $headers
40 | * @return void
41 | */
42 | public function modify(HeaderBag &$headers)
43 | {
44 | if ( ! $headers->has($this->name))
45 | {
46 | foreach ($this->value as $value)
47 | {
48 | $headers->add($this->name, $value);
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Operations/ApplySafeMode.php:
--------------------------------------------------------------------------------
1 | 'sanitizeSTS',
14 | 'public-key-pins' => 'sanitizePKP',
15 | 'expect-ct' => 'sanitizeExpectCT',
16 | ];
17 |
18 | private $exceptions;
19 |
20 | /**
21 | * Create an operation to apply safe mode with exceptions $exceptions
22 | *
23 | * @param array $exceptions
24 | */
25 | public function __construct(array $exceptions = [])
26 | {
27 | $this->exceptions = $exceptions;
28 | }
29 |
30 | /**
31 | * Transform the given set of headers
32 | *
33 | * @param HeaderBag $headers
34 | * @return void
35 | */
36 | public function modify(HeaderBag &$headers)
37 | {
38 | foreach ($headers->get() as $header)
39 | {
40 | $headerName = $header->getName();
41 |
42 | $isUnsafe = array_key_exists($headerName, self::$unsafeHeaders);
43 | $hasException = array_key_exists($headerName, $this->exceptions);
44 |
45 | if ($isUnsafe && ! $hasException)
46 | {
47 | $method = self::$unsafeHeaders[$headerName];
48 |
49 | $this->$method($header);
50 | }
51 | }
52 | }
53 |
54 | /**
55 | * Apply safe-mode for HSTS to $header
56 | *
57 | * @param Header $header
58 | * @return void
59 | */
60 | private function sanitizeSTS(Header $header)
61 | {
62 | $origValue = $header->getValue();
63 |
64 | # Only do these when the attribute exists!
65 | $header->ensureAttributeMaximum('max-age', 86400);
66 | $header->removeAttribute('includeSubDomains');
67 | $header->removeAttribute('preload');
68 |
69 | if ($header->getValue() !== $origValue)
70 | {
71 | $this->addError(
72 | 'HSTS settings were overridden because Safe-Mode is enabled.
73 | Read about some common mistakes when
75 | setting HSTS via copy/paste, and ensure you
76 | understand the
78 | details and possible side effects of this security feature
79 | before using it.'
80 | );
81 | }
82 | }
83 |
84 | /**
85 | * Apply safe-mode for HPKP to $header
86 | *
87 | * @param Header $header
88 | * @return void
89 | */
90 | private function sanitizePKP(Header $header)
91 | {
92 | $origValue = $header->getValue();
93 |
94 | # Only do these when the attributes exist
95 | $header->ensureAttributeMaximum('max-age', 10);
96 | $header->removeAttribute('includeSubDomains');
97 |
98 | if ($header->getValue() !== $origValue)
99 | {
100 | $this->addError(
101 | 'Some HPKP settings were overridden because Safe-Mode is enabled.'
102 | );
103 | }
104 | }
105 |
106 | /**
107 | * Apply safe-mode for Expect-CT to $header
108 | *
109 | * @param Header $header
110 | * @return void
111 | */
112 | private function sanitizeExpectCT(Header $header)
113 | {
114 | $origValue = $header->getValue();
115 |
116 | # Only do these when the attributes exist
117 | $header->removeAttribute('enforce');
118 |
119 | if ($header->getValue() !== $origValue)
120 | {
121 | $this->addError(
122 | 'Some ExpectCT settings were overridden because Safe-Mode is enabled.'
123 | );
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Operations/CompileCSP.php:
--------------------------------------------------------------------------------
1 | [$sendLegacyHeaders, $combineMultiplePolicies]],
44 | [4, 5]
45 | );
46 |
47 | $this->cspConfig = $cspConfig;
48 | $this->csproConfig = $csproConfig;
49 | $this->csproBlacklist = $csproBlacklist;
50 |
51 | $this->sendLegacyHeaders = $sendLegacyHeaders;
52 | $this->combine = $combineMultiplePolicies;
53 | }
54 |
55 | /**
56 | * Transform the given set of headers
57 | *
58 | * @param HeaderBag $headers
59 | * @return void
60 | */
61 | public function modify(HeaderBag &$headers)
62 | {
63 | $cspHeaders = [
64 | 'Content-Security-Policy' => 'csp',
65 | 'Content-Security-Policy-Report-Only' => 'cspro',
66 | ];
67 |
68 | foreach ($cspHeaders as $header => $type)
69 | {
70 | if ($this->combine)
71 | {
72 | $otherPolicyHeaders = $headers->getByName($header);
73 |
74 | $policies = [$this->{$type.'Config'}];
75 |
76 | foreach ($otherPolicyHeaders as $otherPolicy)
77 | {
78 | $policies[]
79 | = self::deconstructCSP($otherPolicy->getValue());
80 | }
81 |
82 | $this->{$type.'Config'} = self::mergeCSPList($policies);
83 | }
84 |
85 | $value = $this->{'compile'.strtoupper($type)}();
86 |
87 | if (empty($value))
88 | {
89 | continue;
90 | }
91 |
92 | $headers->{($this->combine ? 'replace' : 'add')}($header, $value);
93 |
94 | if ($this->sendLegacyHeaders)
95 | {
96 | $headers->{($this->combine ? 'replace' : 'add')}("X-$header", $value);
97 | }
98 | }
99 | }
100 |
101 | /**
102 | * Compile internal CSP config into a CSP header-value string
103 | *
104 | * @return string
105 | */
106 | private function compileCSP()
107 | {
108 | return self::compile($this->cspConfig);
109 | }
110 |
111 | /**
112 | * Compile internal CSPRO config into a CSP header-value string
113 | *
114 | * @return string
115 | */
116 | private function compileCSPRO()
117 | {
118 | # Filter out the blacklisted directives
119 | $filteredConfig = array_diff_key(
120 | $this->csproConfig,
121 | array_flip($this->csproBlacklist)
122 | );
123 |
124 | return self::compile($filteredConfig);
125 | }
126 |
127 | /**
128 | * Compile CSP $config into a CSP header-value string
129 | *
130 | * @param array $config
131 | * @return string
132 | */
133 | public static function compile(array $config)
134 | {
135 | $pieces = [];
136 |
137 | foreach ($config as $directive => $sources)
138 | {
139 | if (is_array($sources))
140 | {
141 | self::removeEmptySources($sources);
142 | self::removeDuplicateSources($sources);
143 |
144 | array_unshift($sources, $directive);
145 |
146 | $pieces[] = implode(' ', $sources);
147 | }
148 | else
149 | {
150 | $pieces[] = $directive;
151 | }
152 | }
153 |
154 | return implode('; ', $pieces);
155 | }
156 |
157 | /**
158 | * Deconstruct $cspString into a CSP config array
159 | *
160 | * @param string $cspString
161 | * @return array
162 | */
163 | public static function deconstructCSP($cspString)
164 | {
165 | $csp = [];
166 |
167 | $directivesAndSources = explode(';', $cspString);
168 |
169 | foreach ($directivesAndSources as $directiveAndSources)
170 | {
171 | $directiveAndSources = ltrim($directiveAndSources);
172 |
173 | $list = explode(' ', $directiveAndSources, 2);
174 |
175 | $directive = strtolower($list[0]);
176 |
177 | if (isset($csp[$directive]))
178 | {
179 | continue;
180 | }
181 |
182 | if (isset($list[1]) and trim($list[1]) !== '')
183 | {
184 | $sourcesString = $list[1];
185 |
186 | $sources = explode(' ', $sourcesString);
187 |
188 | self::removeEmptySources($sources);
189 | }
190 | else
191 | {
192 | $sources = true;
193 | }
194 |
195 | $csp[$directive] = $sources;
196 | }
197 |
198 | return $csp;
199 | }
200 |
201 | /**
202 | * Remove empty sources from $sources
203 | *
204 | * @param array $sources
205 | * @return void
206 | */
207 | private static function removeEmptySources(array &$sources)
208 | {
209 | $sources = array_filter(
210 | $sources,
211 | function ($source)
212 | {
213 | return $source !== '';
214 | }
215 | );
216 | }
217 |
218 | /**
219 | * Remove duplicate sources from $sources
220 | *
221 | * @param array $sources
222 | * @return void
223 | */
224 | private static function removeDuplicateSources(array &$sources)
225 | {
226 | $sources = array_unique($sources, SORT_REGULAR);
227 | }
228 |
229 | /**
230 | * Merge a multiple CSP configs together into a single CSP
231 | *
232 | * @param array $cspList
233 | * @return array
234 | */
235 | public static function mergeCSPList(array $cspList)
236 | {
237 | $finalCSP = [];
238 |
239 | foreach ($cspList as $csp)
240 | {
241 | foreach ($csp as $directive => $sources)
242 | {
243 | if ( ! isset($finalCSP[$directive]))
244 | {
245 | $finalCSP[$directive] = $sources;
246 |
247 | continue;
248 | }
249 | elseif ($finalCSP[$directive] === true)
250 | {
251 | continue;
252 | }
253 | else
254 | {
255 | $finalCSP[$directive] = array_merge(
256 | $finalCSP[$directive],
257 | $sources
258 | );
259 |
260 | continue;
261 | }
262 | }
263 | }
264 |
265 | return $finalCSP;
266 | }
267 | }
268 |
--------------------------------------------------------------------------------
/src/Operations/CompileExpectCT.php:
--------------------------------------------------------------------------------
1 | config = $expectCTConfig;
21 | }
22 |
23 | /**
24 | * Transform the given set of headers
25 | *
26 | * @param HeaderBag $headers
27 | * @return void
28 | */
29 | public function modify(HeaderBag &$headers)
30 | {
31 | $headers->replace(
32 | 'Expect-CT',
33 | $this->makeHeaderValue()
34 | );
35 | }
36 |
37 | /**
38 | * Make the ExpectCT header value
39 | *
40 | * @return string
41 | */
42 | private function makeHeaderValue()
43 | {
44 | $pieces = ['max-age=' . (int) $this->config['max-age']];
45 |
46 | if ($this->config['enforce'])
47 | {
48 | $pieces[] = 'enforce';
49 | }
50 |
51 | if ($this->config['report-uri'])
52 | {
53 | $pieces[] = 'report-uri="' . $this->config['report-uri'] . '"';
54 | }
55 |
56 | return implode('; ', $pieces);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Operations/CompileHPKP.php:
--------------------------------------------------------------------------------
1 | pkpConfig = $pkpConfig;
24 | $this->pkproConfig = $pkproConfig;
25 | }
26 |
27 | /**
28 | * Transform the given set of headers
29 | *
30 | * @param HeaderBag $headers
31 | * @return void
32 | */
33 | public function modify(HeaderBag &$headers)
34 | {
35 | $hpkpHeaders = [
36 | 'Public-Key-Pins' => $this->compilePKP(),
37 | 'Public-Key-Pins-Report-Only' => $this->compilePKPRO(),
38 | ];
39 |
40 | foreach ($hpkpHeaders as $header => $value)
41 | {
42 | if (empty($value))
43 | {
44 | continue;
45 | }
46 |
47 | $headers->replace($header, $value);
48 | }
49 | }
50 |
51 | /**
52 | * Compile internal HPKP config into a HPKP header-value string
53 | *
54 | * @return string
55 | */
56 | private function compilePKP()
57 | {
58 | return $this->compile($this->pkpConfig);
59 | }
60 |
61 | /**
62 | * Compile internal HPKPRO config into a HPKPRO header-value string
63 | *
64 | * @return string
65 | */
66 | private function compilePKPRO()
67 | {
68 | return $this->compile($this->pkproConfig);
69 | }
70 |
71 | /**
72 | * Compile HPKP $config into a HPKP header-value string
73 | *
74 | * @param array $config
75 | * @return string
76 | */
77 | private function compile(array $config)
78 | {
79 | if (empty($config) or empty($config['pins']))
80 | {
81 | return '';
82 | }
83 |
84 | $maxAge = isset($config['max-age']) ? $config['max-age'] : 10;
85 |
86 | $pieces = ["max-age=$maxAge"];
87 |
88 | foreach ($config['pins'] as $pinAlg)
89 | {
90 | list($pin, $alg) = $pinAlg;
91 |
92 | $pieces[] = "pin-$alg=\"$pin\"";
93 | }
94 |
95 | if ($config['includesubdomains'])
96 | {
97 | $pieces[] = 'includeSubDomains';
98 | }
99 |
100 | if ($config['report-uri'])
101 | {
102 | $pieces[] = 'report-uri="' . $config['report-uri'] . '"';
103 | }
104 |
105 | return implode('; ', $pieces);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Operations/CompileHSTS.php:
--------------------------------------------------------------------------------
1 | config = $hstsConfig;
21 | }
22 |
23 | /**
24 | * Transform the given set of headers
25 | *
26 | * @param HeaderBag $headers
27 | * @return void
28 | */
29 | public function modify(HeaderBag &$headers)
30 | {
31 | $headers->replace(
32 | 'Strict-Transport-Security',
33 | $this->makeHeaderValue()
34 | );
35 | }
36 |
37 | /**
38 | * Make the HSTS header value
39 | *
40 | * @return string
41 | */
42 | private function makeHeaderValue()
43 | {
44 | $pieces = ['max-age=' . $this->config['max-age']];
45 |
46 | if ($this->config['subdomains'])
47 | {
48 | $pieces[] = 'includeSubDomains';
49 | }
50 |
51 | if ($this->config['preload'])
52 | {
53 | $pieces[] = 'preload';
54 | }
55 |
56 | return implode('; ', $pieces);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Operations/InjectStrictDynamic.php:
--------------------------------------------------------------------------------
1 | [$mode]], [2]);
29 |
30 | $this->allowedCSPHashAlgs = $allowedCSPHashAlgs;
31 | $this->mode = $mode;
32 | }
33 |
34 | /**
35 | * Transform the given set of headers
36 | *
37 | * @param HeaderBag $HeaderBag
38 | * @return void
39 | */
40 | public function modify(HeaderBag &$HeaderBag)
41 | {
42 | $CSPHeaders = array_merge(
43 | $this->mode & self::ENFORCE ?
44 | $HeaderBag->getByName('content-security-policy') : [],
45 | $this->mode & self::REPORT ?
46 | $HeaderBag->getByName('content-security-policy-report-only') : []
47 | );
48 |
49 | foreach ($CSPHeaders as $Header)
50 | {
51 | $directive = $this->canInjectStrictDynamic($Header);
52 |
53 | if (is_string($directive))
54 | {
55 | $Header->setAttribute($directive, "'strict-dynamic'");
56 | }
57 | elseif ($directive !== -1)
58 | {
59 | $this->addError(
60 | "Strict-Mode is enabled, but
61 | 'strict-dynamic' could not be added to "
62 | . $Header->getFriendlyName()
63 | . ' because no hash or nonce was used.',
64 | E_USER_WARNING
65 | );
66 | }
67 | }
68 | }
69 |
70 | /**
71 | * Determine which directive `'strict-dynamic'` may be injected into, if
72 | * any.
73 | * If Safe-Mode conflicts, `-1` will be returned.
74 | * If `'strict-dynamic'` cannot be injected, `false` will be returned.
75 | *
76 | * @return string|int|bool
77 | */
78 | private function canInjectStrictDynamic(Header $header)
79 | {
80 | # check if a relevant directive exists
81 | if (
82 | $header->hasAttribute($directive = 'script-src')
83 | or $header->hasAttribute($directive = 'default-src')
84 | ) {
85 | if (
86 | preg_match(
87 | "/(?:^|\s)(?:'strict-dynamic'|'none')(?:$|\s)/i",
88 | $header->getAttributeValue($directive)
89 | )
90 | ) {
91 | return -1;
92 | }
93 |
94 | $nonceOrHashRe = implode(
95 | '|',
96 | array_map(
97 | function ($s)
98 | {
99 | return preg_quote($s, '/');
100 | },
101 | array_merge(
102 | ['nonce'],
103 | $this->allowedCSPHashAlgs
104 | )
105 | )
106 | );
107 |
108 | # if the directive contains a nonce or hash, return the directive
109 | # that strict-dynamic should be injected into
110 | $containsNonceOrHash = preg_match(
111 | "/(?:^|\s)'(?:$nonceOrHashRe)-/i",
112 | $header->getAttributeValue($directive)
113 | );
114 |
115 | if ($containsNonceOrHash)
116 | {
117 | return $directive;
118 | }
119 | }
120 |
121 | return false;
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/Operations/ModifyCookies.php:
--------------------------------------------------------------------------------
1 | [$field]], [2]);
28 |
29 | $this->cookieList = $cookieList;
30 | $this->field = $field;
31 | $this->value = $value;
32 | }
33 |
34 | /**
35 | * Create an Operation to modify cookies with names $cookieNames such that
36 | * $field holds $value.
37 | *
38 | * @param array $cookieNames
39 | * @param string $field
40 | * @param $value
41 | * @return Operation
42 | */
43 | public static function matchingFully(array $cookieNames, $field, $value = true)
44 | {
45 | Types::assert(['string' => [$field]], [2]);
46 |
47 | return new static($cookieNames, $field, $value);
48 | }
49 |
50 | /**
51 | * Create an operation to modify cookies with name substrings matching
52 | * $cookieSubstrs such that $field holds $value.
53 | *
54 | * @param array $cookieSubstrs
55 | * @param string $field
56 | * @param $value
57 | * @return Operation
58 | */
59 | public static function matchingPartially(array $cookieSubstrs, $field, $value = true)
60 | {
61 | Types::assert(['string' => [$field]], [2]);
62 |
63 | $instance = new static($cookieSubstrs, $field, $value);
64 | $instance->matchSubstring = true;
65 |
66 | return $instance;
67 | }
68 |
69 | /**
70 | * Transform the given set of headers
71 | *
72 | * @param HeaderBag $headers
73 | * @return void
74 | */
75 | public function modify(HeaderBag &$headers)
76 | {
77 | foreach ($headers->getByName('set-cookie') as $cookieHeader)
78 | {
79 | $cookieName = $cookieHeader->getFirstAttributeName();
80 |
81 | if ( ! $cookieHeader->hasAttribute($this->field) and $this->isCandidateCookie($cookieName))
82 | {
83 | $cookieHeader->setAttribute($this->field, $this->value);
84 | }
85 | }
86 | }
87 |
88 | /**
89 | * Determine whether $cookieName is a candidate for modification by the
90 | * current Operation
91 | *
92 | * @param string $cookieName
93 | * @return bool
94 | */
95 | private function isCandidateCookie($cookieName)
96 | {
97 | Types::assert(['string' => [$cookieName]]);
98 |
99 | if ($this->matchSubstring)
100 | {
101 | return $this->matchesSubstring($cookieName);
102 | }
103 | else
104 | {
105 | return $this->matchesFully($cookieName);
106 | }
107 | }
108 |
109 | /**
110 | * Determine whether $cookieName is a candidate for modification by the
111 | * current Operation's internal substring list
112 | *
113 | * @param string $cookieName
114 | * @return bool
115 | */
116 | private function matchesSubstring($cookieName)
117 | {
118 | Types::assert(['string' => [$cookieName]]);
119 |
120 | foreach ($this->cookieList as $forbidden)
121 | {
122 | if (strpos(strtolower($cookieName), $forbidden) !== false)
123 | {
124 | return true;
125 | }
126 | }
127 |
128 | return false;
129 | }
130 |
131 | /**
132 | * Determine whether $cookieName is a candidate for modification by the
133 | * current Operation's internal cookie name list
134 | *
135 | * @param string $cookieName
136 | * @return bool
137 | */
138 | private function matchesFully($cookieName)
139 | {
140 | Types::assert(['string' => [$cookieName]]);
141 |
142 | return in_array(
143 | strtolower($cookieName),
144 | $this->cookieList,
145 | true
146 | );
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/Operations/OperationWithErrors.php:
--------------------------------------------------------------------------------
1 | errors;
22 |
23 | $this->clearErrors();
24 |
25 | return $errors;
26 | }
27 |
28 | /**
29 | * Clear any stored errors
30 | *
31 | * @param void
32 | * @return void
33 | */
34 | protected function clearErrors()
35 | {
36 | $this->errors = [];
37 | }
38 |
39 | /**
40 | * Return an array of errors, clearing any stored errors
41 | *
42 | * @param string $message
43 | * @param int $level
44 | * @return void
45 | */
46 | protected function addError($message, $level = E_USER_NOTICE)
47 | {
48 | Types::assert(['string' => [$message], 'int' => [$level]]);
49 |
50 | $this->errors[] = new Error($message, $level);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Operations/RemoveCookies.php:
--------------------------------------------------------------------------------
1 | removedCookies = $removedCookies;
20 | }
21 |
22 | /**
23 | * Transform the given set of headers
24 | *
25 | * @param HeaderBag $headers
26 | * @return void
27 | */
28 | public function modify(HeaderBag &$headers)
29 | {
30 | $cookies = $headers->getByName('set-cookie');
31 |
32 | $headers->remove('set-cookie');
33 |
34 | foreach ($cookies as $key => $cookie)
35 | {
36 | $cookieName = $cookie->getFirstAttributeName();
37 |
38 | if ( ! in_array(strtolower($cookieName), $this->removedCookies))
39 | {
40 | $headers->add('Set-Cookie', $cookie->getValue());
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Operations/RemoveHeaders.php:
--------------------------------------------------------------------------------
1 | headersToRemove = $headersToRemove;
20 | }
21 |
22 | /**
23 | * Transform the given set of headers
24 | *
25 | * @param HeaderBag $headers
26 | * @return void
27 | */
28 | public function modify(HeaderBag &$headers)
29 | {
30 | foreach ($this->headersToRemove as $header)
31 | {
32 | $headers->remove($header);
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/SecureHeaders.php:
--------------------------------------------------------------------------------
1 | [
79 | 'sess',
80 | 'auth',
81 | 'login',
82 | 'csrf',
83 | 'xsrf',
84 | 'token',
85 | 'antiforgery'
86 | ],
87 | 'names' => [
88 | 'sid',
89 | 's',
90 | 'persistent'
91 | ]
92 | ];
93 |
94 | protected $headerProposals = [
95 | 'Expect-CT'
96 | => 'max-age=0',
97 | 'Referrer-Policy'
98 | => [
99 | 'no-referrer',
100 | 'strict-origin-when-cross-origin'
101 | ],
102 | 'X-Permitted-Cross-Domain-Policies'
103 | => 'none',
104 | 'X-XSS-Protection'
105 | => '1; mode=block',
106 | 'X-Content-Type-Options'
107 | => 'nosniff',
108 | 'X-Frame-Options'
109 | => 'Deny'
110 | ];
111 |
112 | # ~~
113 | # private variables: (non settings)
114 |
115 | private $removedHeaders = [];
116 |
117 | private $removedCookies = [];
118 |
119 | private $errors = [];
120 | private $errorString;
121 |
122 | private $csp = [];
123 | private $cspro = [];
124 |
125 | private $cspNonces = [
126 | 'enforced' => [],
127 | 'reportOnly' => []
128 | ];
129 |
130 | private $expectCT = [];
131 |
132 | private $hsts = [];
133 |
134 | private $hpkp = [];
135 | private $hpkpro = [];
136 |
137 | private $isBufferReturned = false;
138 |
139 | private $applyOnOutput = null;
140 |
141 | # private variables: (pre-defined static structures)
142 |
143 | private $cspDirectiveShortcuts = [
144 | 'default' => 'default-src',
145 | 'script' => 'script-src',
146 | 'style' => 'style-src',
147 | 'image' => 'img-src',
148 | 'img' => 'img-src',
149 | 'font' => 'font-src',
150 | 'child' => 'child-src',
151 | 'base' => 'base-uri',
152 | 'connect' => 'connect-src',
153 | 'form' => 'form-action',
154 | 'object' => 'object-src',
155 | 'report' => 'report-uri',
156 | 'reporting' => 'report-uri'
157 | ];
158 |
159 | private $cspSourceShortcuts = [
160 | 'self' => "'self'",
161 | 'none' => "'none'",
162 | 'unsafe-inline' => "'unsafe-inline'",
163 | 'unsafe-eval' => "'unsafe-eval'",
164 | 'strict-dynamic' => "'strict-dynamic'"
165 | ];
166 |
167 | protected $csproBlacklist = [
168 | 'block-all-mixed-content',
169 | 'upgrade-insecure-requests'
170 | ];
171 |
172 | private $allowedCSPHashAlgs = [
173 | 'sha256',
174 | 'sha384',
175 | 'sha512'
176 | ];
177 |
178 | private $allowedHPKPAlgs = [
179 | 'sha256'
180 | ];
181 |
182 | private $reportMissingHeaders = [
183 | 'Expect-CT',
184 | 'Strict-Transport-Security',
185 | 'Content-Security-Policy',
186 | 'X-Permitted-Cross-Domain-Policies',
187 | 'X-XSS-Protection',
188 | 'X-Content-Type-Options',
189 | 'X-Frame-Options',
190 | 'Referrer-Policy'
191 | ];
192 |
193 | # ~
194 | # Constants
195 |
196 | # auto-headers
197 |
198 | const AUTO_ADD = 0b0000001;
199 | const AUTO_REMOVE = 0b0000010;
200 |
201 | ## cookie attribute injection
202 | const AUTO_COOKIE_SECURE = 0b0000100;
203 | const AUTO_COOKIE_HTTPONLY = 0b0001000;
204 | const AUTO_COOKIE_SAMESITE = 0b0010000;
205 |
206 | ## opportunistic strict-dynamic injection
207 | const AUTO_STRICTDYNAMIC_ENFORCE = 0b0100000;
208 | const AUTO_STRICTDYNAMIC_REPORT = 0b1000000;
209 | const AUTO_STRICTDYNAMIC = 0b1100000;
210 |
211 | const AUTO_ALL = 0b1111111;
212 |
213 | # cookie upgrades
214 |
215 | const COOKIE_NAME = 0b00001;
216 | const COOKIE_SUBSTR = 0b00010;
217 | const COOKIE_ALL = 0b00011; # COOKIE_NAME | COOKIE_SUBSTR
218 | const COOKIE_REMOVE = 0b00100;
219 | const COOKIE_DEFAULT = 0b00010; # ~COOKIE_REMOVE & COOKIE_SUBSTR
220 |
221 | # ~~
222 | # Public Functions
223 |
224 | /**
225 | * Used to enable or disable output buffering with ob_start.
226 | * When enabled, the ob_start callback will be set to automatically call
227 | * {@see apply} upon the first byte of output.
228 | *
229 | * If unconfigured, the default setting for {@see applyOnOutput} is off.
230 | *
231 | * @api
232 | *
233 | * @param HttpAdapter $http
234 | * @param mixed $mode
235 | * mode is the on/off setting. Any value of type that is loosely castable to a boolean is valid.
236 | *
237 | * Passing a boolean of value true will turn output buffering on,
238 | * passing a boolean of value false will turn it off. The integers
239 | * 1 and 0 will do the same respectively.
240 | *
241 | * @return void
242 | */
243 | public function applyOnOutput(HttpAdapter $http = null, $mode = true)
244 | {
245 | if ($mode == true)
246 | {
247 | if ($this->applyOnOutput === null)
248 | {
249 | ob_start([$this, 'returnBuffer']);
250 | }
251 |
252 | $this->applyOnOutput = $http;
253 | }
254 | elseif ($this->applyOnOutput !== null)
255 | {
256 | ob_end_clean();
257 |
258 | $this->applyOnOutput = null;
259 | }
260 | }
261 |
262 | # ~~
263 | # public functions: settings
264 |
265 | # ~~
266 | # Settings: Safe Mode
267 |
268 | /**
269 | * Used to turn safe mode on or off.
270 | *
271 | * Safe mode will modify certain headers that may cause lasting effects so
272 | * to limit how long accidental effects can last for.
273 | *
274 | * Note that exceptions can be made to safe-mode on a header by header
275 | * basis with {@see safeModeException}
276 | *
277 | * @api
278 | *
279 | * @param mixed $mode
280 | * mode is the on/off setting. Any value of type that is loosely castable to a boolean is valid.
281 | *
282 | * Loosely casted to a boolean, `true` turns safe mode on, `false` turns
283 | * it off. The exception being the string 'off' case-insensitively, which
284 | * will operate as if it was casted to `false` (this makes the behaviour
285 | * more similar to the way some values are set in PHP ini files).
286 | *
287 | * @return void
288 | */
289 | public function safeMode($mode = true)
290 | {
291 | $this->safeMode = ($mode == true and strtolower($mode) !== 'off');
292 | }
293 |
294 | /**
295 | * Used to add an exception to {@see safeMode}.
296 | *
297 | * @api
298 | *
299 | * @param string $name
300 | * Specify the name of the header that you wish to be exempt from
301 | * {@see safeMode} warnings and auto-modification.
302 | *
303 | * (Note that if you want to turn safe mode off for all headers, use
304 | * [`->safeMode(false)`](safeMode) – safe mode is **not** on by default).
305 | *
306 | * @return void
307 | */
308 | public function safeModeException($name)
309 | {
310 | Types::assert(['string' => [$name]]);
311 |
312 | $this->safeModeExceptions[strtolower($name)] = true;
313 | }
314 |
315 | # ~~
316 | # Settings: Strict Mode
317 |
318 | /**
319 | * Turn strict mode on or off.
320 | *
321 | * When enabled, strict mode will:
322 | * * Auto-enable HSTS with a 1 year duration, and the `includeSubDomains`
323 | * and `preload` flags set. Note that this HSTS policy is made as a
324 | * [header proposal](header-proposals), and can thus be removed or
325 | * modified.
326 | *
327 | * Don't forget to [manually submit](https://hstspreload.appspot.com/)
328 | * your domain to the HSTS preload list if you are using this option.
329 | *
330 | * * The source keyword `'strict-dynamic'` will also be added to the first
331 | * of the following directives that exist: `script-src`, `default-src`;
332 | * only if that directive also contains a nonce or hash source value, and
333 | * not otherwise.
334 | *
335 | * This will disable the source whitelist in `script-src` in CSP3
336 | * compliant browsers. The use of whitelists in script-src is
337 | * [considered not to be an ideal practice][1], because they are often
338 | * trivial to bypass.
339 | *
340 | * [1]: https://research.google.com/pubs/pub45542.html "The Insecurity of
341 | * Whitelists and the Future of Content Security Policy"
342 | *
343 | * * The default `SameSite` value injected into {@see protectedCookie} will
344 | * be changed from `SameSite=Lax` to `SameSite=Strict`.
345 | * See [`->auto`](auto#AUTO_COOKIE_SAMESITE) to enable/disable injection
346 | * of `SameSite` and {@see sameSiteCookies} for more on specific behaviour
347 | * and to explicitly define this value manually, to override the default.
348 | *
349 | * * Auto-enable Expect-CT with a 1 year duration, and the `enforce` flag
350 | * set. Note that this Expect-CT policy is made as a
351 | * [header proposal](header-proposals), and can thus be removed or
352 | * modified.
353 | *
354 | * @api
355 | *
356 | * @param mixed $mode
357 | * Loosely casted to a boolean, `true` enables strict mode, `false` turns
358 | * it off.
359 | *
360 | * @return void
361 | */
362 | public function strictMode($mode = true)
363 | {
364 | $this->strictMode = ($mode == true and strtolower($mode) !== 'off');
365 | }
366 |
367 | # ~~
368 | # Settings: Error Reporting
369 |
370 | /**
371 | * Enable or disable error reporting.
372 | *
373 | * Note that SecureHeaders will honour the PHP configuration for error
374 | * reporting level and for whether errors are displayed by default. If you
375 | * would like to specifically turn off errors from only SecureHeaders then
376 | * use this function.
377 | *
378 | * @api
379 | *
380 | * @param mixed $mode
381 | * Loosely casted as a boolean, `true` will enable error reporting
382 | * (the default), `false` will disable it.
383 | *
384 | * @return void
385 | */
386 | public function errorReporting($mode)
387 | {
388 | $this->errorReporting = ($mode == true);
389 | }
390 |
391 | /**
392 | *
393 | * Selectively disable 'Missing security header: ...' reports for a
394 | * specific header.
395 | *
396 | * @api
397 | *
398 | * @param string $name
399 | * The (case-insensitive) name of the header to disable missing reports
400 | * for.
401 | *
402 | * @return void
403 | */
404 | public function reportMissingException($name)
405 | {
406 | Types::assert(['string' => [$name]]);
407 |
408 | $this->reportMissingExceptions[strtolower($name)] = true;
409 | }
410 |
411 | # ~~
412 | # Settings: Automatic Behaviour
413 |
414 | /**
415 | * Enable or disable certain automatically applied header functions
416 | *
417 | * If unconfigured, the default setting is {@see AUTO_ALL}.
418 | *
419 | * @api
420 | *
421 | * @param int $mode
422 | * `auto` accepts one or more of the following constants. Multiple
423 | * constants may be specified by combination using
424 | * [bitwise operators](https://secure.php.net/manual/language.operators.bitwise.php).
425 | *
426 | * You are **strongly** advised to make use of the constants by name,
427 | * and **not** by value. Constant values may be updated at any time
428 | * without a major version bump.
429 | *
430 | * @return void
431 | */
432 | public function auto($mode = self::AUTO_ALL)
433 | {
434 | Types::assert(['int' => [$mode]]);
435 |
436 | $this->automaticHeaders = $mode;
437 | }
438 |
439 | # ~~
440 | # Settings: Nonces
441 |
442 | /**
443 | * Determine the behaviour of {@see cspNonce} and its aliases when
444 | * a nonce for the specified directive already exists.
445 | *
446 | * When enabled, the existing nonce will be returned. When disabled, a new
447 | * nonce will be generated for the directive, added alongside the existing
448 | * one, and the new nonce will be returned.
449 | *
450 | * If not explicitly set, the default mode for this setting is enabled.
451 | *
452 | * @api
453 | *
454 | * @param mixed $mode
455 | * Loosely casted to a boolean, `true` enables the behaviour, `false`
456 | * turns it off.
457 | *
458 | * @return void
459 | */
460 | public function returnExistingNonce($mode = true)
461 | {
462 | $this->returnExistingNonce = ($mode == true);
463 | }
464 |
465 | # ~~
466 | # Settings: Cookies
467 |
468 | /**
469 | * Add and configure the default setting for
470 | * [protected cookies](protectedCookie) that are automatically marked
471 | * as `SameSite`.
472 | *
473 | * If this setting is unspecified the default will be `SameSite=Lax`, if
474 | * this setting is given an invalid `string` setting the last setting will
475 | * be honoured. If {@see strictMode} is enabled then the default
476 | * will be `SameSite=Strict` under the same criteria for set value. If you
477 | * wish to disable making cookies as same site,
478 | * see [`->auto`](auto#AUTO_COOKIE_SAMESITE).
479 | *
480 | * @api
481 | *
482 | * @param ?string $mode
483 | * Valid values for `$mode` are either (case-insensitively) the strings
484 | * `'Lax'` and `'Strict'`. If `null` is passed the setting will revert to
485 | * the default as defined above. If another `string` is passed then the
486 | * call will be ignored and the previous setting will be retained (if no
487 | * setting was specified previously then the default will remain).
488 | *
489 | * @return void
490 | */
491 | public function sameSiteCookies($mode = null)
492 | {
493 | Types::assert(['?string' => [$mode]]);
494 |
495 | if (isset($mode))
496 | {
497 | $mode = strtolower($mode);
498 | }
499 |
500 | if ($mode === 'lax' or $mode === 'strict')
501 | {
502 | $this->sameSiteCookies = ucfirst($mode);
503 | }
504 | elseif ( ! isset($mode))
505 | {
506 | $this->sameSiteCookies = null;
507 | }
508 | }
509 |
510 | # ~~
511 | # public functions: raw headers
512 |
513 | /**
514 | * Queue a header for removal.
515 | *
516 | * Upon calling {@see apply} the header will be removed. This function can
517 | * be used to manually prevent [automatic headers](auto) from being sent.
518 | *
519 | * @api
520 | *
521 | * @param string $name
522 | * Case insensitive name of the header to remove.
523 | *
524 | * @return void
525 | */
526 | public function removeHeader($name)
527 | {
528 | Types::assert(['string' => [$name]]);
529 |
530 | $name = strtolower($name);
531 | $this->removedHeaders[$name] = true;
532 | }
533 |
534 | # ~~
535 | # public functions: cookies
536 |
537 | /**
538 | * Configure which cookies SecureHeaders will regard as protected.
539 | *
540 | * SecureHeaders will consider substrings and names of cookies separately.
541 | * By default, cookies that case insensitively match the following
542 | * substrings or names will be regarded as protected.
543 | *
544 | * #### Substrings
545 | * ```
546 | * sess
547 | * auth
548 | * login
549 | * csrf
550 | * xsrf
551 | * token
552 | * antiforgery
553 | * ```
554 | *
555 | * #### Names
556 | * ```
557 | * sid
558 | * s
559 | * persistent
560 | * ```
561 | *
562 | * If a cookie is protected, then cookie flags will be appended as
563 | * configured by {@see auto}. The default behaviour is to add `Secure`,
564 | * `HttpOnly`, and `SameSite=Lax` to ensure cookies are both sent securely,
565 | * out of the reach of JavaScript, and fairly resistant to csrf attacks.
566 | *
567 | * @api
568 | *
569 | * @param string|array $name
570 | * The name (or substring of the name, depending on mode configuration),
571 | * of the cookie to add/remove from the protection list (depending on mode
572 | * configuration). Or a list of cookie names (or substrings of the name to
573 | * match) as an array of strings.
574 | * @param int $mode
575 | * `mode` accepts one or more of the following constants. Multiple
576 | * constants may be specified by combination using
577 | * [bitwise operators](https://secure.php.net/manual/language.operators.bitwise.php)
578 | *
579 | * @return void
580 | */
581 | public function protectedCookie(
582 | $name,
583 | $mode = self::COOKIE_DEFAULT
584 | ) {
585 | Types::assert(
586 | [
587 | 'string|array' => [$name],
588 | 'int' => [$mode]
589 | ]
590 | );
591 |
592 | if (is_string($name))
593 | {
594 | $name = strtolower($name);
595 | }
596 | elseif (is_array($name))
597 | {
598 | foreach ($name as $cookie)
599 | {
600 | $this->protectedCookie($cookie, $mode);
601 | }
602 | return;
603 | }
604 |
605 | $stringTypes = [];
606 |
607 | if (($mode & self::COOKIE_NAME) === self::COOKIE_NAME)
608 | {
609 | $stringTypes[] = 'names';
610 | }
611 |
612 | if (($mode & self::COOKIE_SUBSTR) === self::COOKIE_SUBSTR)
613 | {
614 | $stringTypes[] = 'substrings';
615 | }
616 |
617 | foreach ($stringTypes as $type)
618 | {
619 | if (
620 | ($mode & self::COOKIE_REMOVE) !== self::COOKIE_REMOVE
621 | and ! in_array($name, $this->protectedCookies[$type])
622 | ) {
623 | $this->protectedCookies[$type][] = $name;
624 | }
625 | elseif (
626 | ($mode & self::COOKIE_REMOVE) === self::COOKIE_REMOVE
627 | and (
628 | $key = array_search(
629 | $name,
630 | $this->protectedCookies[$type]
631 | )
632 | ) !== false
633 | ) {
634 | unset($this->protectedCookies[$type][$key]);
635 | }
636 | }
637 | }
638 |
639 | /**
640 | * Remove a cookie from SecureHeaders' internal list (thus preventing the
641 | * `Set-Cookie` header for that specific cookie from being sent).
642 | *
643 | * This allows you to form a blacklist for cookies that should not be sent
644 | * (either programatically or globally, depending on where this is
645 | * configured).
646 | *
647 | * @api
648 | *
649 | * @param string $name
650 | * The (case-insensitive) name of the cookie to remove.
651 | *
652 | * @return void
653 | */
654 | public function removeCookie($name)
655 | {
656 | Types::assert(['string' => [$name]]);
657 |
658 | $this->removedCookies[] = strtolower($name);
659 | }
660 |
661 | # ~~
662 | # public functions: Content-Security-Policy (CSP)
663 |
664 | /**
665 | * @api
666 | *
667 | * @ignore Polymorphic variadic function
668 | */
669 | public function csp()
670 | {
671 | $args = func_get_args();
672 |
673 | Types::assert(['string|array|int|bool|NULL' => $args]);
674 |
675 | $num = count($args);
676 |
677 | # look for a bool or intgers (commonly used in place of bools)
678 | # if one is found the first of which is loosly interpreted as
679 | # the setting for report only, remaining are ignored
680 | foreach ($args as $arg)
681 | {
682 | if (is_bool($arg) or is_int($arg))
683 | {
684 | $reportOnly = ($arg == true);
685 | break;
686 | }
687 | }
688 | # if no such items can be found, default to enforced csp
689 | if ( ! isset($reportOnly))
690 | {
691 | $reportOnly = false;
692 | }
693 |
694 | # look at all the arguments
695 | for ($i = 0; $i < $num; $i++)
696 | {
697 | $arg = $args[$i];
698 |
699 | # if the arg is an array, then treat is as an entire policy
700 | if (is_array($arg))
701 | {
702 | $this->cspArray($arg, $reportOnly);
703 | }
704 | # if the arg is a string
705 | elseif (is_string($arg))
706 | {
707 | # then the arg is the directive name
708 | $friendlyDirective = $arg;
709 |
710 | # if we've specified a source value (string: source,
711 | # or null: directive is flag)
712 | if (
713 | ($i + 1 < $num)
714 | and (is_string($args[$i+1]) or is_null($args[$i+1]))
715 | ) {
716 | # then use the value we specified, and skip over the next
717 | # item in the loop (since we just used it as a source value)
718 | $friendlySource = $args[$i+1];
719 | $i++;
720 | }
721 | # if no source is specified (either no more args, or one of
722 | # unsupported type)
723 | else
724 | {
725 | # assume that the directive is a flag
726 | $friendlySource = null;
727 | }
728 |
729 | $this->cspAllow(
730 | $friendlyDirective,
731 | $friendlySource,
732 | $reportOnly
733 | );
734 | }
735 | }
736 | }
737 |
738 | /**
739 | * @api
740 | *
741 | * @ignore Polymorphic variadic function
742 | */
743 | public function cspro()
744 | {
745 | $args = func_get_args();
746 |
747 | Types::assert(['string|array|int|bool|NULL' => $args]);
748 |
749 | foreach ($args as $i => $arg)
750 | {
751 | if (is_bool($arg) or is_int($arg))
752 | {
753 | unset($args[$i]);
754 | }
755 | }
756 |
757 | $args = array_values($args);
758 |
759 | array_unshift($args, true);
760 |
761 | call_user_func_array([$this, 'csp'], $args);
762 | }
763 |
764 | # Content-Security-Policy: Settings
765 |
766 | /**
767 | * Enable or disable legacy CSP support.
768 | *
769 | * When enabled, SecureHeaders will send an additional
770 | * `X-Content-Security-Policy` and/or
771 | * `X-Content-Security-Policy-Report-Only`. The policy configured with
772 | * {@see csp} or {@see cspro} respectively will be sent with this legacy
773 | * header, with no attempt to strip out newer CSP features (browsers should
774 | * ignore CSP directives and keywords they do not recognise).
775 | *
776 | * If this setting is unconfigured, the default is off.
777 | *
778 | * @api
779 | *
780 | * @param mixed $mode
781 | * Loosely casted as a boolean, `true` enables the legacy headers, `false`
782 | * disables them.
783 | *
784 | * @return void
785 | */
786 | public function cspLegacy($mode = true)
787 | {
788 | $this->cspLegacy = ($mode == true);
789 | }
790 |
791 | # Content-Security-Policy: Policy string removals
792 |
793 | /**
794 | * Remove a previously added source from a CSP directive.
795 | *
796 | * @api
797 | *
798 | * @param string $directive
799 | * The directive (case insensitive) in which the source to be removed
800 | * resides.
801 | * @param string $source
802 | * The source (case insensitive) to remove.
803 | * @param mixed $reportOnly
804 | * Loosely casted as a boolean, `true` ensures the function acts on the
805 | * report only policy, `false` (the default, as `null` casts to false)
806 | * ensures the function acts on the enforced policy.
807 | *
808 | * @return void
809 | */
810 | public function removeCSPSource($directive, $source, $reportOnly = null)
811 | {
812 | Types::assert(['string' => [$directive, $source]]);
813 |
814 | $csp = &$this->getCSPObject($reportOnly);
815 |
816 | $source = strtolower($source);
817 | $directive = strtolower($directive);
818 |
819 | if ( ! isset($csp[$directive][$source]))
820 | {
821 | return false;
822 | }
823 |
824 | unset($csp[$directive][$source]);
825 |
826 | return true;
827 | }
828 |
829 | /**
830 | * Remove a previously added directive from CSP.
831 | *
832 | * @api
833 | *
834 | * @param string $directive
835 | * The directive (case insensitive) to remove.
836 | * @param mixed $reportOnly
837 | * Loosely casted as a boolean, `true` ensures the function acts on the
838 | * report only policy, `false` (the default, as `null` casts to false)
839 | * ensures the function acts on the enforced policy.
840 | *
841 | * @return void
842 | */
843 | public function removeCSPDirective($directive, $reportOnly = null)
844 | {
845 | Types::assert(['string' => [$directive]]);
846 |
847 | $csp = &$this->getCSPObject($reportOnly);
848 |
849 | $directive = strtolower($directive);
850 |
851 | if ( ! isset($csp[$directive]))
852 | {
853 | return false;
854 | }
855 |
856 | unset($csp[$directive]);
857 |
858 | return true;
859 | }
860 |
861 | /**
862 | * Reset the CSP.
863 | *
864 | * @api
865 | *
866 | * @param mixed $reportOnly
867 | * Loosely casted to a boolean, `true` resets the policy configured by
868 | * {@see cspro}, `false` resets the policy configured by {@see csp}.
869 | *
870 | * @return void
871 | */
872 | public function resetCSP($reportOnly = null)
873 | {
874 | $csp = &$this->getCSPObject($reportOnly);
875 |
876 | $csp = [];
877 | }
878 |
879 | # Content-Security-Policy: Hashing
880 |
881 | /**
882 | * Generate a hash of the provided [`$string`](#string) value, and have it
883 | * added to the [`$friendlyDirective`](#friendlyDirective) directive in CSP.
884 | *
885 | * @api
886 | *
887 | * @param string $friendlyDirective
888 | * The (case insensitive)
889 | * [friendly name](friendly_directives_and_sources#directives) that the
890 | * hash should be to be added to.
891 | * @param string $string
892 | * The string that should be hashed and added to the
893 | * [`$friendlyDirective`](friendly_directives_and_sources#directives)
894 | * directive.
895 | * @param ?string $algo = 'sha256'
896 | * The hashing algorithm to use. CSP currently supports `sha256`,
897 | * `sha384`, `sha512`.
898 | * @param mixed $isFile
899 | * Loosely casted as a boolean. Indicates that [`$string`](string) instead
900 | * specifies a file path.
901 | * @param mixed $reportOnly
902 | * Loosely casted as a boolean. Indicates that the hash should be added
903 | * to the report only policy `true`, or the enforced policy `false`.
904 | *
905 | * @return string
906 | * Returns the hash value.
907 | */
908 | public function cspHash(
909 | $friendlyDirective,
910 | $string,
911 | $algo = null,
912 | $isFile = null,
913 | $reportOnly = null
914 | ) {
915 | Types::assert(
916 | ['string' => [$friendlyDirective, $string], '?string' => [$algo]]
917 | );
918 |
919 | if (
920 | ! isset($algo)
921 | or ! in_array(
922 | strtolower($algo),
923 | $this->allowedCSPHashAlgs
924 | )
925 | ) {
926 | $algo = 'sha256';
927 | }
928 |
929 | $hash = $this->cspDoHash($string, $algo, $isFile);
930 |
931 | $hashString = "'$algo-$hash'";
932 |
933 | $this->cspAllow($friendlyDirective, $hashString, $reportOnly);
934 |
935 | return $hash;
936 | }
937 |
938 | /**
939 | * An alias for {@see cspHash} with [reportOnly](cspHash#reportOnly)
940 | * set to true.
941 | *
942 | * @api
943 | *
944 | * @param string $friendlyDirective
945 | * @param string $string
946 | * @param ?string $algo = 'sha256'
947 | * @param mixed $isFile
948 | *
949 | * @return string
950 | */
951 | public function csproHash(
952 | $friendlyDirective,
953 | $string,
954 | $algo = null,
955 | $isFile = null
956 | ) {
957 | Types::assert(
958 | ['string' => [$friendlyDirective, $string], '?string' => [$algo]]
959 | );
960 |
961 | return $this->cspHash(
962 | $friendlyDirective,
963 | $string,
964 | $algo,
965 | $isFile,
966 | true
967 | );
968 | }
969 |
970 | /**
971 | * An alias for {@see cspHash} with [isFile](cspHash#isFile) set to `true`.
972 | *
973 | * @api
974 | *
975 | * @param string $friendlyDirective
976 | * @param string $string
977 | * @param ?string $algo = 'sha256'
978 | * @param mixed $reportOnly
979 | *
980 | * @return string
981 | */
982 | public function cspHashFile(
983 | $friendlyDirective,
984 | $string,
985 | $algo = null,
986 | $reportOnly = null
987 | ) {
988 | Types::assert(
989 | ['string' => [$friendlyDirective, $string], '?string' => [$algo]]
990 | );
991 |
992 | return $this->cspHash(
993 | $friendlyDirective,
994 | $string,
995 | $algo,
996 | true,
997 | $reportOnly
998 | );
999 | }
1000 |
1001 | /**
1002 | * An alias for {@see cspHash} with [reportOnly](cspHash#reportOnly) set
1003 | * to true, and [isFile](cspHash#isFile) set to true.
1004 | *
1005 | * @api
1006 | *
1007 | * @param string $friendlyDirective
1008 | * @param string $string
1009 | * @param ?string $algo = 'sha256'
1010 | *
1011 | * @return string
1012 | */
1013 | public function csproHashFile($friendlyDirective, $string, $algo = null)
1014 | {
1015 | Types::assert(
1016 | ['string' => [$friendlyDirective, $string], '?string' => [$algo]]
1017 | );
1018 |
1019 | return $this->cspHash($friendlyDirective, $string, $algo, true, true);
1020 | }
1021 |
1022 | # Content-Security-Policy: Nonce
1023 |
1024 | /**
1025 | * Used to securely generate a nonce value, and have it be added to the
1026 | * [`$friendlyDirective`](#friendlyDirective) in CSP.
1027 | *
1028 | * Note that if a nonce already exists for the specified directive, the
1029 | * existing value will be returned instead of generating a new one
1030 | * (multiple nonces in the same directive don't offer any security benefits
1031 | * at present – since they're all treated equally). This should facilitate
1032 | * distributing the nonce to any code that needs it (provided the code can
1033 | * access the SecureHeaders instance).
1034 | *
1035 | * If you want to disable returning an existing nonce, use
1036 | * {@see returnExistingNonce} to turn the behaviour on or off.
1037 |
1038 | * **Make sure not to use nonces where the content given the nonce is
1039 | * partially of user origin! This would allow an attacker to bypass the
1040 | * protections of CSP!**
1041 | *
1042 | * @api
1043 | *
1044 | * @param string $friendlyDirective
1045 | * The (case insensitive)
1046 | * [friendly name](friendly_directives_and_sources#directives) that the
1047 | * nonce should be to be added to.
1048 | * @param mixed $reportOnly
1049 | * Loosely casted as a boolean. Indicates that the hash should be added to
1050 | * the report only policy `true`, or the enforced policy `false`.
1051 | *
1052 | * @return string
1053 | * Returns the nonce value.
1054 | */
1055 | public function cspNonce($friendlyDirective, $reportOnly = null)
1056 | {
1057 | Types::assert(['string' => [$friendlyDirective]]);
1058 |
1059 | $reportOnly = ($reportOnly == true);
1060 |
1061 | $nonceStore = &$this->cspNonces[
1062 | ($reportOnly ? 'reportOnly' : 'enforced')
1063 | ];
1064 |
1065 | $directive = $this->longDirective($friendlyDirective);
1066 |
1067 | if ($this->returnExistingNonce and isset($nonceStore[$directive]))
1068 | {
1069 | return $nonceStore[$directive];
1070 | }
1071 |
1072 | $nonce = $this->cspGenerateNonce();
1073 |
1074 | $nonceString = "'nonce-$nonce'";
1075 |
1076 | $this->addCSPSource($directive, $nonceString, $reportOnly);
1077 |
1078 | $nonceStore[$directive] = $nonce;
1079 |
1080 | return $nonce;
1081 | }
1082 |
1083 | /**
1084 | * An alias for {@see cspNonce} with [reportOnly](cspNonce#reportOnly)
1085 | * set to true.
1086 | *
1087 | * **Make sure not to use nonces where the content given the nonce is
1088 | * partially of user origin! This would allow an attacker to bypass the
1089 | * protections of CSP!**
1090 | *
1091 | * @api
1092 | *
1093 | * @param string $friendlyDirective
1094 | *
1095 | * @return string
1096 | */
1097 | public function csproNonce($friendlyDirective)
1098 | {
1099 | Types::assert(['string' => [$friendlyDirective]]);
1100 |
1101 | return $this->cspNonce($friendlyDirective, true);
1102 | }
1103 |
1104 | # ~~
1105 | # public functions: Expect-CT
1106 |
1107 | /**
1108 | * Used to add and configure the Expect-CT header.
1109 | *
1110 | * Expect-CT makes sure that a user's browser will fill the role of
1111 | * ensuring that future requests, within $maxAge seconds will have
1112 | * certificate transparancy.
1113 | *
1114 | * If set to enforcement mode, the browser will fail the TLS connection if
1115 | * the certificate transparency requirement is not met
1116 | *
1117 | * @api
1118 | *
1119 | * @param ?int|string $maxAge
1120 | * The length, in seconds either as a string, or an integer – specify the
1121 | * length that a user's browser should remember that the application
1122 | * should be delivered with a certificate transparency expectation.
1123 | *
1124 | * @param ?mixed $enforce
1125 | * Loosely casted as a boolean, whether to enforce (by failing the TLS
1126 | * connection) that certificate transparency is enabled for the next
1127 | * $maxAge seconds, or whether to only report to the console, and to
1128 | * $reportUri if an address is defined.
1129 | *
1130 | * @param ?string $reportUri
1131 | * A reporting address to send violation reports to.
1132 | *
1133 | * Passing `null` indicates that a reporting address should not be modified
1134 | * on this call (e.g. can be used to prevent overwriting a previous
1135 | * setting).
1136 | *
1137 | * @return void
1138 | */
1139 | public function expectCT(
1140 | $maxAge = 31536000,
1141 | $enforce = true,
1142 | $reportUri = null
1143 | ) {
1144 | Types::assert(
1145 | [
1146 | '?int|?string' => [$maxAge],
1147 | '?string' => [$reportUri]
1148 | ],
1149 | [1, 3]
1150 | );
1151 |
1152 | if (isset($maxAge) or ! isset($this->expectCT['max-age']))
1153 | {
1154 | $this->expectCT['max-age'] = $maxAge;
1155 | }
1156 |
1157 | if (isset($enforce) or ! isset($this->expectCT['enforce']))
1158 | {
1159 | $this->expectCT['enforce']
1160 | = (isset($enforce) ? ($enforce == true) : null);
1161 | }
1162 |
1163 | if (isset($reportUri) or ! isset($this->expectCT['report-uri']))
1164 | {
1165 | $this->expectCT['report-uri'] = $reportUri;
1166 | }
1167 | }
1168 |
1169 | # ~~
1170 | # public functions: HSTS
1171 |
1172 | /**
1173 | * Used to add and configure the Strict-Transport-Security header.
1174 | *
1175 | * HSTS makes sure that a user's browser will fill the role of redirecting
1176 | * them from HTTP to HTTPS so that they need not trust an insecure response
1177 | * from the network.
1178 | *
1179 | * @api
1180 | *
1181 | * @param int|string $maxAge
1182 | * The length, in seconds either as a string, or an integer – specify the
1183 | * length that a user's browser should remember that the application is
1184 | * HTTPS only.
1185 | *
1186 | * @param mixed $subdomains
1187 | * Loosely casted as a boolean, whether to include the `includeSubDomains`
1188 | * flag – to deploy the HSTS policy across the entire domain.
1189 | *
1190 | * @param mixed $preload
1191 | * Loosely casted as a boolean, whether to include the `preload` flag – to
1192 | * consent to have the domain loaded into
1193 | * [various preload lists](https://hstspreload.appspot.com/) (so that a
1194 | * user need not initially visit your site securely to know about the
1195 | * HSTS policy).
1196 | *
1197 | * You must also [manually preload](https://hstspreload.appspot.com/)
1198 | * your domain for this to take effect – the flag just indicates consent.
1199 | *
1200 | * @return void
1201 | */
1202 | public function hsts(
1203 | $maxAge = 31536000,
1204 | $subdomains = false,
1205 | $preload = false
1206 | ) {
1207 | Types::assert(['int|string' => [$maxAge]]);
1208 |
1209 | $this->hsts['max-age'] = $maxAge;
1210 | $this->hsts['subdomains'] = ($subdomains == true);
1211 | $this->hsts['preload'] = ($preload == true);
1212 | }
1213 |
1214 | /**
1215 | * Add or remove the `includeSubDomains` flag from the [HSTS](hsts) policy
1216 | * (note this can be done with the {@see hsts} function too).
1217 | *
1218 | * @api
1219 | *
1220 | * @param mixed $mode
1221 | * Loosely casted to a boolean, `true` adds the `includeSubDomains` flag,
1222 | * `false` removes it.
1223 | *
1224 | * @return void
1225 | */
1226 | public function hstsSubdomains($mode = true)
1227 | {
1228 | $this->hsts['subdomains'] = ($mode == true);
1229 | }
1230 |
1231 | /**
1232 | * Add or remove the `preload` flag from the [HSTS](hsts) policy (note this
1233 | * can be done with the {@see hsts} function too).
1234 | *
1235 | * @api
1236 | *
1237 | * @param mixed $mode
1238 | * Loosely casted to a boolean, `true` adds the `preload` flag, `false`
1239 | * removes it.
1240 | *
1241 | * @return void
1242 | */
1243 | public function hstsPreload($mode = true)
1244 | {
1245 | $this->hsts['preload'] = ($mode == true);
1246 | }
1247 |
1248 | # ~~
1249 | # public functions: HPKP
1250 |
1251 | /**
1252 | * Add and configure the HTTP Public Key Pins header.
1253 | *
1254 | * @param string|array $pins
1255 | * Either give a valid pin as a string here, or give multiple as an array.
1256 | * **Note that browsers will not enforce this header unless a backup pin
1257 | * AND a pin that is currently deployed is specified)**. This means that
1258 | * at least two pins must be specified. (to do this by passing strings,
1259 | * simply call {@see hpkp} again with the second pin as the first
1260 | * argument).
1261 | *
1262 | * Valid array syntax is as follows
1263 | * ```php
1264 | * $pins = array(
1265 | * array('sha256', 'pin1'),
1266 | * array('pin2'),
1267 | * array('pin3', 'sha256')
1268 | * );
1269 | * $headers->hpkp($pins);
1270 | * ```
1271 | *
1272 | * The above will add `pin1`, `pin2`, and `pin3` with the associated hash
1273 | * label `sha256`. This is the only valid * HPKP hashing algorithm at
1274 | * time of writing.
1275 | *
1276 | * @api
1277 | *
1278 | * @param ?integer|string $maxAge
1279 | * The length, in seconds that a browser should enforce the policy after
1280 | * last receiving it.
1281 | *
1282 | * If this is left unset across all calls to {@see hpkp}, the value will
1283 | * default to 10 seconds (which isn't much use – so it is best to set the
1284 | * value).
1285 | *
1286 | * Passing `null` indicates that a maxAge should not be modified on this
1287 | * call (e.g. can be used to prevent overwriting a previous setting).
1288 | *
1289 | * @param ?mixed $subdomains
1290 | * Loosely casted to a boolean, whether to include the `includeSubDomains`
1291 | * flag to deploy the policy across the entire domain. `true` enables this
1292 | * flag.
1293 | *
1294 | * Passing `null` indicates that a subdomains should not be modified on
1295 | * this call (e.g. can be used to prevent overwriting a previous setting).
1296 | *
1297 | * @param ?string $reportUri
1298 | * A reporting address to send violation reports to.
1299 | *
1300 | * Passing `null` indicates that a reporting address should not be modified
1301 | * on this call (e.g. can be used to prevent overwriting a previous
1302 | * setting).
1303 | *
1304 | * @param mixed $reportOnly
1305 | * Loosely cased to a boolean. If `true`, settings will apply to the
1306 | * report-only version of this header.
1307 | *
1308 | * @return void
1309 | */
1310 | public function hpkp(
1311 | $pins,
1312 | $maxAge = null,
1313 | $subdomains = null,
1314 | $reportUri = null,
1315 | $reportOnly = null
1316 | ) {
1317 | Types::assert(
1318 | [
1319 | 'string|array' => [$pins],
1320 | '?int|?string' => [$maxAge],
1321 | '?string' => [$reportUri]
1322 | ],
1323 | [1, 2, 4]
1324 | );
1325 |
1326 | $hpkp = &$this->getHPKPObject($reportOnly);
1327 |
1328 | # set single values
1329 |
1330 | if (isset($maxAge) or ! isset($this->hpkp['max-age']))
1331 | {
1332 | $hpkp['max-age'] = $maxAge;
1333 | }
1334 |
1335 | if (isset($subdomains) or ! isset($this->hpkp['includesubdomains']))
1336 | {
1337 | $hpkp['includesubdomains']
1338 | = (isset($subdomains) ? ($subdomains == true) : null);
1339 | }
1340 |
1341 | if (isset($reportUri) or ! isset($this->hpkp['report-uri']))
1342 | {
1343 | $hpkp['report-uri'] = $reportUri;
1344 | }
1345 |
1346 | if ( ! is_array($pins))
1347 | {
1348 | $pins = [$pins];
1349 | }
1350 |
1351 | # set pins
1352 |
1353 | foreach ($pins as $key => $pin)
1354 | {
1355 | if (is_array($pin) and count($pin) === 2)
1356 | {
1357 | $res = array_intersect($pin, $this->allowedHPKPAlgs);
1358 |
1359 | if ( ! empty($res))
1360 | {
1361 | $key = key($res);
1362 | $hpkp['pins'][] = [
1363 | $pin[($key + 1) % 2],
1364 | $pin[$key]
1365 | ];
1366 | }
1367 | else
1368 | {
1369 | continue;
1370 | }
1371 | }
1372 | elseif (
1373 | is_string($pin) or (is_array($pin)
1374 | and count($pin) === 1
1375 | and ($pin = $pin[0]) !== false)
1376 | ) {
1377 | $hpkp['pins'][] = [$pin, 'sha256'];
1378 | }
1379 | }
1380 | }
1381 |
1382 | /**
1383 | * Add and configure the HTTP Public Key Pins header in report-only mode.
1384 | * This is an alias for {@see hpkp} with `$reportOnly` set to `true`.
1385 | *
1386 | * @api
1387 | *
1388 | * @param string|array $pins
1389 | * @param ?integer|string $maxAge
1390 | * @param ?mixed $subdomains
1391 | * @param ?string $reportUri
1392 | *
1393 | * @return void
1394 | */
1395 | public function hpkpro(
1396 | $pins,
1397 | $maxAge = null,
1398 | $subdomains = null,
1399 | $reportUri = null
1400 | ) {
1401 | Types::assert(
1402 | [
1403 | 'string|array' => [$pins],
1404 | '?int|?string' => [$maxAge],
1405 | '?string' => [$reportUri]
1406 | ],
1407 | [1, 2, 4]
1408 | );
1409 |
1410 | return $this->hpkp($pins, $maxAge, $subdomains, $reportUri, true);
1411 | }
1412 |
1413 | /**
1414 | * Add or remove the `includeSubDomains` flag from the [HPKP](hpkp) policy
1415 | * (note this can be done with the {@see hpkp} function too).
1416 | *
1417 | * @api
1418 | *
1419 | * @param mixed $mode
1420 | * Loosely casted to a boolean, `true` adds the `includeSubDomains` flag,
1421 | * `false` removes it.
1422 | * @param mixed $reportOnly
1423 | * Apply this setting to the report-only version of the HPKP policy header
1424 | *
1425 | * @return void
1426 | */
1427 | public function hpkpSubdomains($mode = true, $reportOnly = null)
1428 | {
1429 | $hpkp = &$this->getHPKPObject($reportOnly);
1430 |
1431 | $hpkp['includesubdomains'] = ($mode == true);
1432 | }
1433 |
1434 | /**
1435 | * An alias for {@see hpkpSubdomains} with `$reportOnly` set to `true`
1436 | *
1437 | * @api
1438 | *
1439 | * @param mixed $mode
1440 | *
1441 | * @return void
1442 | */
1443 | public function hpkproSubdomains($mode = true)
1444 | {
1445 | return $this->hpkpSubdomains($mode, true);
1446 | }
1447 |
1448 | # ~~
1449 | # public functions: general
1450 |
1451 | /**
1452 | * Calling this function will initiate the following
1453 | *
1454 | * 1. Existing headers from the HttpAdapter's source will be imported into
1455 | * SecureHeaders' internal list, parsed
1456 | * 2. [Automatic header functions](auto) will be applied
1457 | * 3. [Expect CT](expectCT), [CSP](csp), [HSTS](hsts), and [HPKP](hpkp)
1458 | * policies will be compiled and added to SecureHeaders' internal header
1459 | * list.
1460 | * 4. Headers queued for [removal](removeHeader) will be deleted from
1461 | * SecureHeaders' internal header list
1462 | * 5. [Safe Mode](safeMode) will examine the list of headers, and make any
1463 | * required changes according to its settings
1464 | * 6. The HttpAdapter will be instructed to remove all headers from its
1465 | * header source, Headers will then be copied from SecureHeaders'
1466 | * internal header list, into the HttpAdapter's (now empty) list of
1467 | * headers
1468 | * 7. If [error reporting](errorReporting) is enabled (both within
1469 | * SecureHeaders and according to the PHP configuration values for
1470 | * error reporting, and whether to display errors)
1471 | * * Missing security headers will be reported as `E_USER_WARNING`
1472 | * * Misconfigured headers will be reported as `E_USER_WARNING` or
1473 | * `E_USER_NOTICE` depending on severity, the former being most
1474 | * severe an issue.
1475 | *
1476 | * **Note:** Calling this function is **required** before the first byte
1477 | * of output in order for SecureHeaders to (be able to) do anything. If
1478 | * you're not sure when the first byte of output might occur, or simply
1479 | * don't want to have to call this every time – take a look at
1480 | * {@see applyOnOutput} to have SecureHeaders take care of this for you.
1481 | *
1482 | * @api
1483 | *
1484 | * @param ?HttpAdapter $http = new GlobalHttpAdapter
1485 | * An implementation of the {@see HttpAdapter} interface, to which
1486 | * settings configured via SecureHeaders will be applied.
1487 | *
1488 | * @return HeaderBag
1489 | * Returns the headers
1490 | */
1491 | public function apply(HttpAdapter $http = null)
1492 | {
1493 | # For ease of use, we allow calling this method without an adapter,
1494 | # which will cause the headers to be sent with PHP's global methods.
1495 | if (is_null($http))
1496 | {
1497 | $http = new GlobalHttpAdapter();
1498 | }
1499 |
1500 | $headers = $http->getHeaders();
1501 |
1502 | foreach ($this->pipeline() as $operation)
1503 | {
1504 | $operation->modify($headers);
1505 |
1506 | if ($operation instanceof ExposesErrors)
1507 | {
1508 | $this->errors = array_merge(
1509 | $this->errors,
1510 | $operation->collectErrors()
1511 | );
1512 | }
1513 | }
1514 |
1515 | $http->sendHeaders($headers);
1516 |
1517 | $this->reportMissingHeaders($headers);
1518 | $this->validateHeaders($headers);
1519 | $this->reportErrors();
1520 |
1521 | return $headers;
1522 | }
1523 |
1524 | /**
1525 | * Return an array of header operations, depending on current configuration.
1526 | *
1527 | * These can then be applied to e.g. the current set of headers.
1528 | *
1529 | * @api
1530 | *
1531 | * @return Operation[]
1532 | */
1533 | private function pipeline()
1534 | {
1535 | $operations = [];
1536 |
1537 | if ($this->strictMode)
1538 | {
1539 | $operations[] = new AddHeader(
1540 | 'Strict-Transport-Security',
1541 | 'max-age=31536000; includeSubDomains; preload'
1542 | );
1543 |
1544 | $operations[] = new AddHeader(
1545 | 'Expect-CT',
1546 | 'max-age=31536000; enforce'
1547 | );
1548 | }
1549 |
1550 | # Apply security headers for all (HTTP and HTTPS) connections
1551 | if ($this->automatic(self::AUTO_ADD))
1552 | {
1553 | foreach ($this->headerProposals as $header => $value)
1554 | {
1555 | $operations[] = new AddHeader($header, $value);
1556 | }
1557 | }
1558 |
1559 | if ($this->automatic(self::AUTO_REMOVE))
1560 | {
1561 | $operations[] = new RemoveHeaders(
1562 | ['Server', 'X-Powered-By']
1563 | );
1564 | }
1565 |
1566 | # Add a secure flag to cookies that look like they hold session data
1567 | if ($this->automatic(self::AUTO_COOKIE_SECURE))
1568 | {
1569 | $operations[] = ModifyCookies::matchingPartially(
1570 | $this->protectedCookies['substrings'],
1571 | 'Secure'
1572 | );
1573 | $operations[] = ModifyCookies::matchingFully(
1574 | $this->protectedCookies['names'],
1575 | 'Secure'
1576 | );
1577 | }
1578 |
1579 | # Add a httpOnly flag to cookies that look like they hold session data
1580 | if ($this->automatic(self::AUTO_COOKIE_HTTPONLY))
1581 | {
1582 | $operations[] = ModifyCookies::matchingPartially(
1583 | $this->protectedCookies['substrings'],
1584 | 'HttpOnly'
1585 | );
1586 | $operations[] = ModifyCookies::matchingFully(
1587 | $this->protectedCookies['names'],
1588 | 'HttpOnly'
1589 | );
1590 | }
1591 |
1592 | if (
1593 | ($this->automaticHeaders & self::AUTO_COOKIE_SAMESITE)
1594 | === self::AUTO_COOKIE_SAMESITE
1595 | ) {
1596 | # add SameSite to cookies that look like they hold
1597 | # session data
1598 |
1599 | $sameSite = $this->injectableSameSiteValue();
1600 |
1601 | $operations[] = ModifyCookies::matchingPartially(
1602 | $this->protectedCookies['substrings'],
1603 | 'SameSite',
1604 | $sameSite
1605 | );
1606 | $operations[] = ModifyCookies::matchingFully(
1607 | $this->protectedCookies['names'],
1608 | 'SameSite',
1609 | $sameSite
1610 | );
1611 | }
1612 |
1613 | $operations[] = new CompileCSP(
1614 | $this->csp,
1615 | $this->cspro,
1616 | $this->csproBlacklist,
1617 | $this->cspLegacy
1618 | );
1619 |
1620 | if ( ! empty($this->hsts))
1621 | {
1622 | $operations[] = new CompileHSTS($this->hsts);
1623 | }
1624 |
1625 | if ( ! empty($this->expectCT))
1626 | {
1627 | $operations[] = new CompileExpectCT($this->expectCT);
1628 | }
1629 |
1630 | $operations[] = new CompileHPKP($this->hpkp, $this->hpkpro);
1631 |
1632 | $operations[] = new RemoveCookies(array_keys($this->removedCookies));
1633 |
1634 | # Remove all headers that were configured to be removed
1635 | $operations[] = new RemoveHeaders(array_keys($this->removedHeaders));
1636 |
1637 | if ($this->strictMode)
1638 | {
1639 | $operations[] = new InjectStrictDynamic(
1640 | $this->allowedCSPHashAlgs,
1641 | ($this->automaticHeaders & self::AUTO_STRICTDYNAMIC_ENFORCE ? InjectStrictDynamic::ENFORCE : 0)
1642 | | ($this->automaticHeaders & self::AUTO_STRICTDYNAMIC_REPORT ? InjectStrictDynamic::REPORT : 0)
1643 | );
1644 | }
1645 |
1646 | if ($this->safeMode)
1647 | {
1648 | $operations[] = new ApplySafeMode($this->safeModeExceptions);
1649 | }
1650 |
1651 | return $operations;
1652 | }
1653 |
1654 | # ~~
1655 | # public functions: non-user
1656 | #
1657 | # These aren't documented because they aren't meant to be used directly,
1658 | # but still need to have public visability.
1659 | #
1660 | # This function is NOT part of the public API guarenteed by symver
1661 |
1662 | /**
1663 | * @ignore
1664 | *
1665 | * Method given to `ob_start` when using {@see applyOnOutput)
1666 | *
1667 | * @param string $buffer
1668 | * @return string
1669 | */
1670 | public function returnBuffer($buffer = null)
1671 | {
1672 | if ($this->isBufferReturned)
1673 | {
1674 | return $buffer;
1675 | }
1676 |
1677 | $this->apply($this->applyOnOutput);
1678 |
1679 | if (ob_get_level() and ! empty($this->errorString))
1680 | {
1681 | # prepend any errors to the buffer string (any errors that were
1682 | # echoed will have been lost during an ob_start callback)
1683 | $buffer = $this->errorString . $buffer;
1684 | }
1685 |
1686 | # if we were called as part of ob_start, make note of this
1687 | # (avoid doing redundent work if called again)
1688 | $this->isBufferReturned = true;
1689 |
1690 | return $buffer;
1691 | }
1692 |
1693 | # ~~
1694 | # Private Functions
1695 |
1696 | # ~~
1697 | # private functions: validation
1698 |
1699 | /**
1700 | * Validate headers in the HeaderBag and store any errors internally.
1701 | *
1702 | * @param HeaderBag $headers
1703 | * @return void
1704 | */
1705 | private function validateHeaders(HeaderBag $headers)
1706 | {
1707 | $this->errors = array_merge(
1708 | $this->errors,
1709 | Validator::validate($headers)
1710 | );
1711 | }
1712 |
1713 | # ~~
1714 | # private functions: Content-Security-Policy (CSP)
1715 |
1716 | # Content-Security-Policy: Policy string additions
1717 |
1718 | /**
1719 | * Add a CSP friendly source $friendlySource to a CSP directive
1720 | * $friendlyDirective in either enforcement or report only mode.
1721 | *
1722 | * @param string $friendlyDirective
1723 | * @param string $friendlySource
1724 | * @param bool $reportOnly
1725 | * @return void
1726 | */
1727 | private function cspAllow(
1728 | $friendlyDirective,
1729 | $friendlySource = null,
1730 | $reportOnly = null
1731 | ) {
1732 | Types::assert(
1733 | ['string' => [$friendlyDirective, $friendlySource]]
1734 | );
1735 |
1736 | $directive = $this->longDirective($friendlyDirective);
1737 |
1738 | $source = $this->longSource($friendlySource);
1739 |
1740 | $this->addCSPSource($directive, $source, $reportOnly);
1741 | }
1742 |
1743 | /**
1744 | * Takes friendly directive $friendlyDirective and returns the
1745 | * corresponding long (proper) directive.
1746 | *
1747 | * @param string $friendlyDirective
1748 | * @return string
1749 | */
1750 | private function longDirective($friendlyDirective)
1751 | {
1752 | Types::assert(['string' => [$friendlyDirective]]);
1753 |
1754 | $friendlyDirective = strtolower($friendlyDirective);
1755 |
1756 | if (isset($this->cspDirectiveShortcuts[$friendlyDirective]))
1757 | {
1758 | $directive = $this->cspDirectiveShortcuts[$friendlyDirective];
1759 | }
1760 | else
1761 | {
1762 | $directive = $friendlyDirective;
1763 | }
1764 |
1765 | return $directive;
1766 | }
1767 |
1768 | /**
1769 | * Takes friendly source $friendlySource and returns the
1770 | * corresponding long (proper) source.
1771 | *
1772 | * @param string $friendlySource
1773 | * @return string
1774 | */
1775 | private function longSource($friendlySource)
1776 | {
1777 | Types::assert(['string' => [$friendlySource]]);
1778 |
1779 | $lowerFriendlySource = strtolower($friendlySource);
1780 |
1781 | if (isset($this->cspSourceShortcuts[$lowerFriendlySource]))
1782 | {
1783 | $source = $this->cspSourceShortcuts[$lowerFriendlySource];
1784 | }
1785 | else
1786 | {
1787 | $source = $friendlySource;
1788 | }
1789 |
1790 | return $source;
1791 | }
1792 |
1793 | /**
1794 | * Add a CSP source $source to a CSP directive $directive in either
1795 | * enforcement or report only mode. Both $directive and $source must be
1796 | * long (defined in CSP spec).
1797 | *
1798 | * Will return false on error, true on success.
1799 | *
1800 | * @param string $directive
1801 | * @param ?string $source
1802 | * @param bool $reportOnly
1803 | * @return bool
1804 | */
1805 | private function addCSPSource(
1806 | $directive,
1807 | $source = null,
1808 | $reportOnly = null
1809 | ) {
1810 | Types::assert(['string' => [$directive], '?string' => [$source]]);
1811 |
1812 | $csp = &$this->getCSPObject($reportOnly);
1813 |
1814 | if ( ! isset($csp[$directive]))
1815 | {
1816 | $this->addCSPDirective(
1817 | $directive,
1818 | ! isset($source),
1819 | $reportOnly
1820 | );
1821 | }
1822 |
1823 | if ($csp[$directive] === null)
1824 | {
1825 | return false;
1826 | }
1827 |
1828 | if (isset($source))
1829 | {
1830 | $source = str_replace(';', '', $source);
1831 |
1832 | $csp[$directive][strtolower($source)] = $source;
1833 | }
1834 |
1835 | return true;
1836 | }
1837 |
1838 | # Content-Security-Policy: Policy as array
1839 |
1840 | /**
1841 | * Add a CSP array $csp of friendly sources to corresponding
1842 | * firendly directives in either enforcement or report only mode.
1843 | *
1844 | * @param array $csp
1845 | * @param bool $reportOnly
1846 | * @return void
1847 | */
1848 | private function cspArray(array $csp, $reportOnly = false)
1849 | {
1850 | foreach ($csp as $friendlyDirective => $sources)
1851 | {
1852 | if (is_array($sources) and ! empty($sources))
1853 | {
1854 | foreach ($sources as $friendlySource)
1855 | {
1856 | $this->cspAllow(
1857 | $friendlyDirective,
1858 | $friendlySource,
1859 | $reportOnly
1860 | );
1861 | }
1862 | }
1863 | elseif (is_int($friendlyDirective) and is_string($sources))
1864 | {
1865 | # special case that $sources is actually a directive name,
1866 | # with an int index
1867 | $friendlyDirective = $sources;
1868 |
1869 | # we'll treat this case as a CSP flag
1870 | $this->cspAllow($friendlyDirective, null, $reportOnly);
1871 | }
1872 | elseif ( ! is_array($sources))
1873 | {
1874 | # special case that $sources isn't an array (possibly a string
1875 | # source, or null
1876 | $this->cspAllow($friendlyDirective, $sources, $reportOnly);
1877 | }
1878 | }
1879 | }
1880 |
1881 | /**
1882 | * Retrieve a reference to either the CSP enforcement, or CSP report only
1883 | * array.
1884 | *
1885 | * @param bool $reportOnly
1886 | * @return &array
1887 | */
1888 | private function &getCSPObject($reportOnly)
1889 | {
1890 | if ( ! isset($reportOnly) or ! $reportOnly)
1891 | {
1892 | $csp = &$this->csp;
1893 | }
1894 | else
1895 | {
1896 | $csp = &$this->cspro;
1897 | }
1898 |
1899 | return $csp;
1900 | }
1901 |
1902 | /**
1903 | * Add a CSP directive $directive in either enforcement or report only mode.
1904 | * $directive must be long (defined in CSP spec). Set $isFlag to true if
1905 | * adding a directive that should not hold source values.
1906 | *
1907 | * Will return false on error, true on success.
1908 | *
1909 | * @param string $directive
1910 | * @param bool $isFlag
1911 | * @param bool $reportOnly
1912 | * @return bool
1913 | */
1914 | private function addCSPDirective(
1915 | $directive,
1916 | $isFlag = null,
1917 | $reportOnly = null
1918 | ) {
1919 | Types::assert(['string' => [$directive]]);
1920 |
1921 | if ( ! isset($isFlag))
1922 | {
1923 | $isFlag = false;
1924 | }
1925 |
1926 | $csp = &$this->getCSPObject($reportOnly);
1927 |
1928 | if (isset($csp[$directive]))
1929 | {
1930 | return false;
1931 | }
1932 |
1933 | if ( ! $isFlag)
1934 | {
1935 | $csp[$directive] = [];
1936 | }
1937 | else
1938 | {
1939 | $csp[$directive] = null;
1940 | }
1941 |
1942 | return true;
1943 | }
1944 |
1945 | /**
1946 | * Generate a hash with algorithm $algo for insertion in a CSP either of
1947 | * $string, or of the contents of a file at path $string iff $isFile is
1948 | * truthy.
1949 | *
1950 | * @param string $string
1951 | * @param string $algo
1952 | * @param bool $isFile
1953 | * @return string
1954 | */
1955 | private function cspDoHash(
1956 | $string,
1957 | $algo = null,
1958 | $isFile = null
1959 | ) {
1960 | Types::assert(['string' => [$string], '?string' => [$algo]]);
1961 |
1962 | if ( ! isset($algo))
1963 | {
1964 | $algo = 'sha256';
1965 | }
1966 |
1967 | if ( ! isset($isFile))
1968 | {
1969 | $isFile = false;
1970 | }
1971 |
1972 | if ( ! $isFile)
1973 | {
1974 | $hash = hash($algo, $string, true);
1975 | }
1976 | else
1977 | {
1978 | if (file_exists($string))
1979 | {
1980 | $hash = hash_file($algo, $string, true);
1981 | }
1982 | else
1983 | {
1984 | $this->addError(
1985 | __FUNCTION__.': The specified file '
1986 | . "'$string', does not exist"
1987 | );
1988 |
1989 | return '';
1990 | }
1991 | }
1992 |
1993 | return base64_encode($hash);
1994 | }
1995 |
1996 | /**
1997 | * Generate a nonce for insertion in a CSP.
1998 | *
1999 | * @return string
2000 | */
2001 | private function cspGenerateNonce()
2002 | {
2003 | $nonce = base64_encode(
2004 | openssl_random_pseudo_bytes(30, $isCryptoStrong)
2005 | );
2006 |
2007 | if ( ! $isCryptoStrong)
2008 | {
2009 | $this->addError(
2010 | 'OpenSSL (openssl_random_pseudo_bytes) reported that it did
2011 | not use a cryptographically strong algorithm
2012 | to generate the nonce for CSP.',
2013 |
2014 | E_USER_WARNING
2015 | );
2016 | }
2017 |
2018 | return $nonce;
2019 | }
2020 |
2021 | # ~~
2022 | # private functions: HPKP
2023 |
2024 | /**
2025 | * Retrieve a reference to either the HPKP enforcement, or HPKP report only
2026 | * array.
2027 | *
2028 | * @param bool $reportOnly
2029 | * @return &array
2030 | */
2031 | private function &getHPKPObject($reportOnly)
2032 | {
2033 | if ( ! isset($reportOnly) or ! $reportOnly)
2034 | {
2035 | $hpkp = &$this->hpkp;
2036 | }
2037 | else
2038 | {
2039 | $hpkp = &$this->hpkpro;
2040 | }
2041 |
2042 | return $hpkp;
2043 | }
2044 |
2045 | # ~~
2046 | # private functions: general
2047 |
2048 | /**
2049 | * Add and store an error internally.
2050 | *
2051 | * @param string $message
2052 | * @param int $level
2053 | * @return void
2054 | */
2055 | private function addError($message, $level = E_USER_NOTICE)
2056 | {
2057 | Types::assert(
2058 | ['string' => [$message], 'int' => [$level]]
2059 | );
2060 |
2061 | $this->errors[] = new Error($message, $level);
2062 | }
2063 |
2064 | /**
2065 | * Use PHPs `trigger_error` function to trigger all internally stored
2066 | * errors if error reporting is enabled for $this. The error handler will
2067 | * be temporarily set to {@see errorHandler} while errors are dispatched via
2068 | * `trigger_error`.
2069 | *
2070 | * @return void
2071 | */
2072 | private function reportErrors()
2073 | {
2074 | if ( ! $this->errorReporting)
2075 | {
2076 | return;
2077 | }
2078 |
2079 | set_error_handler([get_class(), 'errorHandler']);
2080 |
2081 | if ( ! empty($this->errors))
2082 | {
2083 | $this->isBufferReturned = true;
2084 | }
2085 |
2086 | foreach ($this->errors as $error)
2087 | {
2088 | trigger_error($error->getMessage(), $error->getLevel());
2089 | }
2090 |
2091 | restore_error_handler();
2092 | }
2093 |
2094 | /**
2095 | * Determine the appropriate sameSite value to inject.
2096 | *
2097 | * @return string
2098 | */
2099 | private function injectableSameSiteValue()
2100 | {
2101 | if ( ! isset($this->sameSiteCookies) and $this->strictMode)
2102 | {
2103 | $sameSite = 'Strict';
2104 | }
2105 | elseif ( ! isset($this->sameSiteCookies))
2106 | {
2107 | $sameSite = 'Lax';
2108 | }
2109 | else
2110 | {
2111 | $sameSite = $this->sameSiteCookies;
2112 | }
2113 |
2114 | return $sameSite;
2115 | }
2116 |
2117 | /**
2118 | * Echo an error iff PHPs settings allow error reporting, at the level of
2119 | * errors given, and PHPs display_errors setting is on. Will return `true`
2120 | * if an error is echoed, `false` otherwise.
2121 | *
2122 | * @param int $level
2123 | * @param string $message
2124 | * @return bool
2125 | */
2126 | private function errorHandler($level, $message)
2127 | {
2128 | Types::assert(
2129 | ['int' => [$level], 'string' => [$message]]
2130 | );
2131 |
2132 | if (error_reporting() & $level
2133 | and (strtolower(ini_get('display_errors')) === 'on'
2134 | and ini_get('display_errors'))
2135 | ) {
2136 | if ($level === E_USER_NOTICE)
2137 | {
2138 | $error = 'Notice: ' .$message. "
\n\n";
2139 | }
2140 | elseif ($level === E_USER_WARNING)
2141 | {
2142 | $error = 'Warning: ' .$message. "
\n\n";
2143 | }
2144 |
2145 | if (isset($error))
2146 | {
2147 | echo $error;
2148 | $this->errorString .= $error;
2149 | return true;
2150 | }
2151 | }
2152 | return false;
2153 | }
2154 |
2155 | /**
2156 | * If $headers is missing certain headers of security value that are not on
2157 | * the user-defined exception to reporting list then internally store an
2158 | * error warning that the header is not present.
2159 | *
2160 | * @param HeaderBag $headers
2161 | * @return void
2162 | */
2163 | private function reportMissingHeaders(HeaderBag $headers)
2164 | {
2165 | foreach ($this->reportMissingHeaders as $header)
2166 | {
2167 | if (
2168 | ! $headers->has($header)
2169 | and empty($this->reportMissingExceptions[strtolower($header)])
2170 | ) {
2171 | $this->addError(
2172 | "Missing security header: '$header'",
2173 | E_USER_WARNING
2174 | );
2175 | }
2176 | }
2177 | }
2178 |
2179 | /**
2180 | * Determine whether the given $operation may be executed based on the
2181 | * user-controllable automatic settings.
2182 | *
2183 | * @param int $operation
2184 | * @return bool
2185 | */
2186 | private function automatic($operation)
2187 | {
2188 | return ($this->automaticHeaders & $operation) === $operation;
2189 | }
2190 | }
2191 |
--------------------------------------------------------------------------------
/src/Util/TypeError.php:
--------------------------------------------------------------------------------
1 | 0; $i--)
24 | {
25 | if (isset($backtrace[$i]['file']))
26 | {
27 | break;
28 | }
29 | }
30 |
31 | $caller = $backtrace[$i];
32 |
33 | $typeError = new static(
34 | "Argument $argumentNum passed to "
35 | ."${caller['class']}::${caller['function']}() must be of"
36 | ." the type $expectedType, $actualType given in "
37 | ."${caller['file']} on line ${caller['line']}"
38 | );
39 |
40 | throw $typeError;
41 | }
42 |
43 | /**
44 | * Display the Exception as a string
45 | *
46 | * @return string
47 | */
48 | public function __toString()
49 | {
50 | return 'exception '.__CLASS__." '{$this->message}'\n"."{$this->getTraceAsString()}";
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Util/Types.php:
--------------------------------------------------------------------------------
1 | $vars)
23 | {
24 | $type = self::normalizeType($type);
25 |
26 | foreach ($vars as $var)
27 | {
28 | $allowedTypes = explode('|', $type);
29 |
30 | $nullAllowed = false;
31 |
32 | foreach ($allowedTypes as $i => $t)
33 | {
34 | if (strlen($t) > 0 and $t[0] === '?')
35 | {
36 | $nullAllowed = true;
37 | $allowedTypes[$i] = substr($t, 1);
38 | }
39 | }
40 |
41 | if ($nullAllowed)
42 | {
43 | $allowedTypes[] = 'NULL';
44 | }
45 |
46 | if ( ! in_array(($varType = gettype($var)), $allowedTypes))
47 | {
48 | if ( ! isset($argNums))
49 | {
50 | $argNums = self::generateArgNums($typeList);
51 | }
52 |
53 | throw TypeError::fromBacktrace($argNums[$i], $type, $varType, 2);
54 | }
55 |
56 | $i++;
57 | }
58 | }
59 | }
60 |
61 | /**
62 | * Generate sequential argument numbers for $typeList.
63 | *
64 | * @param array $typeList
65 | * @return int[]
66 | */
67 | private static function generateArgNums(array $typeList)
68 | {
69 | $n = array_sum(array_map(
70 | function ($vars)
71 | {
72 | return count((array) $vars);
73 | },
74 | $typeList
75 | ));
76 |
77 | return range(1, $n);
78 | }
79 |
80 | /**
81 | * Normalise the given type name, $type.
82 | *
83 | * @param string $type
84 | * @return string
85 | */
86 | private static function normalizeType($type)
87 | {
88 | return preg_replace(
89 | [
90 | '/bool(?=$|[\|])/',
91 | '/int(?=$|[\|])/'
92 | ],
93 | [
94 | 'boolean',
95 | 'integer'
96 | ],
97 | strtolower($type)
98 | );
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Validator.php:
--------------------------------------------------------------------------------
1 | [self::CSP, self::CSPRO],
15 | 'CSPWildcards'
16 | => [self::CSP, self::CSPRO],
17 | 'CSPRODestination'
18 | => self::CSPRO
19 | ];
20 |
21 | /**
22 | * Validate the given headers
23 | *
24 | * @param HeaderBag $headers
25 | *
26 | * @return Error[]
27 | */
28 | public static function validate(HeaderBag $headers)
29 | {
30 | $errors = [];
31 |
32 | foreach (self::$delegates as $delegate => $headerList)
33 | {
34 | $class = self::VALIDATOR_NAMESPACE.'\\'.$delegate;
35 |
36 | if ( ! is_array($headerList))
37 | {
38 | $headerList = [$headerList];
39 | }
40 |
41 | foreach ($headerList as $headerName)
42 | {
43 | $headers->forEachNamed(
44 | $headerName,
45 | function (Header $header) use (&$errors, $class)
46 | {
47 | $errors = array_merge(
48 | $errors,
49 | $class::validate($header)
50 | );
51 | }
52 | );
53 | }
54 | }
55 |
56 | return $errors;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/ValidatorDelegate.php:
--------------------------------------------------------------------------------
1 | [$attributeName]], [2]);
43 |
44 | $Errors = [];
45 |
46 | if ($header->hasAttribute($attributeName))
47 | {
48 | $value = $header->getAttributeValue($attributeName);
49 |
50 | $badFlags = ["'unsafe-inline'", "'unsafe-eval'"];
51 | foreach ($badFlags as $badFlag)
52 | {
53 | if (strpos($value, $badFlag) !== false)
54 | {
55 | $friendlyHeader = $header->getFriendlyName();
56 |
57 | $Errors[] = new Error(
58 | $friendlyHeader . ' contains the '
59 | . $badFlag . ' keyword in ' . $attributeName
60 | . ', which prevents CSP protecting
61 | against the injection of arbitrary code
62 | into the page.',
63 |
64 | E_USER_WARNING
65 | );
66 | }
67 | }
68 | }
69 |
70 | return $Errors;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/ValidatorDelegates/CSPRODestination.php:
--------------------------------------------------------------------------------
1 | hasAttribute('report-uri')
24 | or ! preg_match(
25 | '/https:\/\/[a-z0-9\-]+[.][a-z]{2,}.*/i',
26 | $header->getAttributeValue('report-uri')
27 | )
28 | ) {
29 | $friendlyHeader = $header->getFriendlyName();
30 |
31 | $errors[] = new Error(
32 | $friendlyHeader.' header was sent,
33 | but an invalid, unsafe, or no reporting address was given.
34 | This header will not enforce violations, and with no
35 | reporting address specified, the browser can only
36 | report them locally in its console. Consider adding
37 | a reporting address to make full use of this header.'
38 | );
39 | }
40 |
41 | return $errors;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/ValidatorDelegates/CSPWildcards.php:
--------------------------------------------------------------------------------
1 | forEachAttribute(
58 | function ($directive, $sources) use ($header, &$errors)
59 | {
60 | // $sources may be bool if directive is a CSP flag
61 | if ( ! is_string($sources))
62 | {
63 | return;
64 | }
65 |
66 | $errors[] = self::enumerateWildcards(
67 | $header,
68 | $directive,
69 | $sources
70 | );
71 | $errors[] = self::enumerateNonHttps(
72 | $header,
73 | $directive,
74 | $sources
75 | );
76 | }
77 | );
78 |
79 | return array_values(array_filter($errors));
80 | }
81 |
82 | /**
83 | * Find wildcards in CSP directives
84 | *
85 | * @param Header $header
86 | * @param string $directive
87 | * @param string $sources
88 | *
89 | * @return ?Error
90 | */
91 | private static function enumerateWildcards(
92 | Header $header,
93 | $directive,
94 | $sources
95 | ) {
96 | Types::assert(['string' => [$directive, $sources]], [2, 3]);
97 |
98 | if (preg_match_all(self::CSP_SOURCE_WILDCARD_RE, $sources, $matches))
99 | {
100 | if ( ! in_array($directive, self::$cspSensitiveDirectives))
101 | {
102 | # if we're not looking at one of the above, we'll
103 | # be a little less strict with data:
104 | if (($key = array_search('data:', $matches[0])) !== false)
105 | {
106 | unset($matches[0][$key]);
107 | }
108 | }
109 |
110 | if ( ! empty($matches[0]))
111 | {
112 | $friendlyHeader = $header->getFriendlyName();
113 |
114 | return new Error(
115 | $friendlyHeader . ' ' . (count($matches[0]) > 1 ?
116 | 'contains the following wildcards '
117 | : 'contains a wildcard ')
118 | . '' . implode(', ', $matches[0]) . ' as a
119 | source value in ' . $directive . '; this can
120 | allow anyone to insert elements covered by
121 | the ' . $directive . ' directive into the
122 | page.',
123 |
124 | E_USER_WARNING
125 | );
126 | }
127 | }
128 |
129 | return null;
130 | }
131 |
132 | /**
133 | * Find non secure origins in CSP directives
134 | *
135 | * @param Header $header
136 | * @param string $directive
137 | * @param string $sources
138 | *
139 | * @return ?Error
140 | */
141 | private static function enumerateNonHttps(
142 | Header $header,
143 | $directive,
144 | $sources
145 | ) {
146 | Types::assert(['string' => [$directive, $sources]], [2, 3]);
147 |
148 | if (preg_match_all('/(?:[ ]|^)\Khttp[:][^ ]*/', $sources, $matches))
149 | {
150 | $friendlyHeader = $header->getFriendlyName();
151 |
152 | return new Error(
153 | $friendlyHeader . ' contains the insecure protocol
154 | HTTP in ' . (count($matches[0]) > 1 ?
155 | 'the following source values '
156 | : 'a source value ')
157 | . '' . implode(', ', $matches[0]) . '; this can
158 | allow anyone to insert elements covered by the
159 | ' . $directive . ' directive into the page.',
160 |
161 | E_USER_WARNING
162 | );
163 | }
164 |
165 | return null;
166 | }
167 | }
168 |
--------------------------------------------------------------------------------