├── .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 [![Build Status](https://travis-ci.org/aidantwoods/SecureHeaders.svg?branch=master)](https://travis-ci.org/aidantwoods/SecureHeaders) [![Build Status](https://ci.appveyor.com/api/projects/status/github/aidantwoods/SecureHeaders?branch=master&svg=true&retina=true)](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 | --------------------------------------------------------------------------------