├── .eslintrc ├── .gitignore ├── modules └── @apostrophecms │ └── template-security-headers │ └── index.js ├── CHANGELOG.md ├── package.json ├── LICENSE.md ├── .github └── workflows │ └── main.yml ├── index.js └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /modules/@apostrophecms/template-security-headers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: '@apostrophecms/template', 3 | extendMethods(self) { 4 | return { 5 | getRenderArgs(_super, req, data, module) { 6 | const args = _super(req, data, module); 7 | req.nonce = req.nonce || self.apos.util.generateId(); 8 | args.nonce = req.nonce; 9 | return args; 10 | } 11 | }; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.2 (2024-07-10) 4 | 5 | ### Changes 6 | 7 | * Add `blob:` to `img-src` directive to support Media Manager calls to `URL.createObjectURL`. 8 | 9 | ## 1.0.1 - 2023-03-06 10 | 11 | ### Fixes 12 | 13 | * Removes `apostrophe` as a peer dependency. 14 | 15 | ## 1.0.0 - 2023-01-16 16 | 17 | * Declared stable. No code changes. 18 | 19 | ## 1.0.0-beta - 2021-12-15 20 | 21 | * First version for Apostrophe 3.x. This version has much less permissive defaults. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/security-headers", 3 | "version": "1.0.2", 4 | "description": "This module sends the modern HTTP security headers that are expected by various security scanners.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/apostrophecms/security-headers.git" 14 | }, 15 | "homepage": "https://github.com/apostrophecms/security-headers#readme", 16 | "author": "Apostrophe Technologies", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "eslint": "^7.9.0", 20 | "eslint-config-apostrophe": "^3.4.0", 21 | "eslint-config-standard": "^14.1.1", 22 | "eslint-plugin-import": "^2.22.0", 23 | "eslint-plugin-node": "^11.1.0", 24 | "eslint-plugin-promise": "^4.2.1", 25 | "eslint-plugin-standard": "^4.0.1" 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: [ '*' ] 9 | pull_request: 10 | branches: [ '*' ] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [14, 16, 18] 24 | mongodb-version: [4.2, 4.4, 5.0] 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | - name: Git checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | 36 | - name: Start MongoDB 37 | uses: supercharge/mongodb-github-action@1.3.0 38 | with: 39 | mongodb-version: ${{ matrix.mongodb-version }} 40 | 41 | - run: npm install 42 | 43 | - run: npm test 44 | env: 45 | CI: true 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | bundle: { 6 | directory: 'modules', 7 | modules: getBundleModuleNames() 8 | }, 9 | options: { 10 | // 1 year. Do not include subdomains as they could be unrelated sites 11 | 'Strict-Transport-Security': 'max-age=31536000', 12 | // You may also set to DENY, if you are not using features that 13 | // iframe the site within itself, but that can be useful 14 | 'X-Frame-Options': 'SAMEORIGIN', 15 | 'X-Content-Type-Options': 'nosniff', 16 | // Very new. Used to entirely disable browser features like geolocation. 17 | // Since we don't know what your site uses, we don't try to set this 18 | // header by default (false means "don't send the header") 19 | 'Permissions-Policy': false, 20 | // Don't send a "Referer" (sp) header unless the new URL shares the same 21 | // origin. You can set this to `false` if you prefer cross-origin "Referer" 22 | // headers be sent. Apostrophe does not rely on them. 23 | 'Referrer-Policy': 'same-origin', 24 | // `true` means it should be computed according to the `policies` option, 25 | // which receives defaults from the `minimumPolicies` option. You may also 26 | // pass your own string, which disables all `policies` sub-options and just 27 | // sends that string, or `false` to not send this header at all. 28 | 'Content-Security-Policy': true, 29 | 30 | minimumPolicies: { 31 | general: { 32 | 'default-src': 'HOSTS', 33 | // Because it is necessary for some of the output of the tiptap 34 | // rich text editor shipped with Apostrophe 35 | 'style-src': 'HOSTS \'unsafe-inline\'', 36 | 'script-src': 'HOSTS', 37 | 'font-src': 'HOSTS', 38 | 'img-src': 'HOSTS blob:', 39 | 'frame-src': '\'self\'' 40 | }, 41 | 42 | // Set this sub-option to false if you wish to forbid google fonts 43 | googleFonts: { 44 | 'style-src': 'fonts.googleapis.com', 45 | 'font-src': 'fonts.gstatic.com' 46 | }, 47 | 48 | oembed: { 49 | 'frame-src': '*.youtube.com *.vimeo.com', 50 | 'img-src': '*.ytimg.com' 51 | }, 52 | 53 | analytics: { 54 | 'default-src': '*.google-analytics.com *.doubleclick.net', 55 | // Note that use of google tag manager by definition brings in scripts from 56 | // more third party sites and you will need to add policies for them 57 | 'script-src': '*.google-analytics.com *.doubleclick.net *.googletagmanager.com' 58 | } 59 | }, 60 | 61 | policies: {} 62 | }, 63 | 64 | handlers(self) { 65 | return { 66 | 'apostrophe:modulesRegistered': { 67 | determineSecurityHeaders() { 68 | self.securityHeaders = {}; 69 | const simple = [ 70 | 'Strict-Transport-Security', 71 | 'X-Frame-Options', 72 | 'Referrer-Policy', 73 | 'Permissions-Policy', 74 | 'X-Content-Type-Options' 75 | ]; 76 | for (const header of simple) { 77 | if (self.options[header]) { 78 | self.securityHeaders[header] = self.options[header]; 79 | } 80 | } 81 | const hosts = self.legitimateHosts(); 82 | if (self.options['Content-Security-Policy'] === true) { 83 | const hostsString = hosts.join(' '); 84 | const policies = {}; 85 | const source = Object.assign({}, self.options.minimumPolicies, self.options.policies || {}); 86 | for (const policy of Object.values(source)) { 87 | for (const [ key, val ] of Object.entries(policy)) { 88 | if (!policy) { 89 | continue; 90 | } 91 | if (policies[key]) { 92 | policies[key] += ` ${val}`; 93 | } else { 94 | policies[key] = val; 95 | } 96 | } 97 | } 98 | let flatPolicies = []; 99 | for (const [ key, val ] of Object.entries(policies)) { 100 | // Merge hosts and permissions from several 'style-src', 'default-src', etc. 101 | // spread over different policies like defaultPolicies and googleFontsPolicies 102 | const words = val.split(/\s+/); 103 | const newWords = []; 104 | for (const word of words) { 105 | if (!newWords.includes(word)) { 106 | newWords.push(word); 107 | } 108 | } 109 | flatPolicies.push(`${key} ${newWords.join(' ')}`); 110 | } 111 | flatPolicies = flatPolicies.map(policy => policy.replace(/HOSTS/g, hostsString)); 112 | self.securityHeaders['Content-Security-Policy'] = flatPolicies.join('; '); 113 | } else if (self.options['Content-Security-Policy']) { 114 | self.securityHeaders['Content-Security-Policy'] = self.options['Content-Security-Policy']; 115 | } 116 | } 117 | } 118 | }; 119 | }, 120 | 121 | middleware(self) { 122 | return { 123 | sendHeaders(req, res, next) { 124 | req.nonce = self.apos.util.generateId(); 125 | // For performance we precomputed everything 126 | for (let [ key, value ] of Object.entries(self.securityHeaders)) { 127 | if (key === 'Content-Security-Policy') { 128 | // We can't precompute the nonce because it is per-request 129 | value = value.replace('script-src ', `script-src 'nonce-${req.nonce}' `); 130 | } 131 | res.setHeader(key, value); 132 | } 133 | return next(); 134 | } 135 | }; 136 | }, 137 | 138 | methods(self) { 139 | return { 140 | legitimateHosts() { 141 | if (self.options.legitimateHosts) { 142 | return self.options.legitimateHosts; 143 | } 144 | const hosts = []; 145 | if (self.apos.baseUrl) { 146 | hosts.push(self.parseHostname(self.apos.baseUrl)); 147 | } 148 | for (const locale of Object.values(self.apos.i18n.locales)) { 149 | if (locale.hostname) { 150 | hosts.push(locale.hostname); 151 | } 152 | } 153 | const mediaUrl = self.apos.attachment.uploadfs.getUrl(); 154 | if (mediaUrl.includes('//')) { 155 | hosts.push(self.parseHostname(mediaUrl)); 156 | } 157 | // Inner quotes intentional 158 | hosts.push('\'self\''); 159 | // Keep unique 160 | return Array.from(new Set(hosts)); 161 | }, 162 | parseHostname(url) { 163 | const parsed = new URL(url); 164 | return parsed.hostname; 165 | } 166 | }; 167 | } 168 | }; 169 | 170 | function getBundleModuleNames() { 171 | const source = path.join(__dirname, './modules/@apostrophecms'); 172 | return fs 173 | .readdirSync(source, { withFileTypes: true }) 174 | .filter(dirent => dirent.isDirectory()) 175 | .map(dirent => `@apostrophecms/${dirent.name}`); 176 | } 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ApostropheCMS logo 3 | 4 |

Security Headers

5 |

6 | 7 | 8 | 9 | 10 | GitHub Workflow Status (branch) 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 |
20 | 21 | This module sends the modern HTTP security headers that are expected by various security scanners. The default settings are strict in most regards, so see below for adjustments you may wish to make. 22 | 23 | ## Warning 24 | 25 | Some third-party services, including Google Analytics, Google Fonts, YouTube and Vimeo, are included as allowed sources for HTML, CSS and scripts in the standard configuration. However even with these permissive settings not all third-party services compatible with Apostrophe will be permitted out of the box. For instance, because they are used relatively rarely, no special testing has been done for Wufoo or Infogram. You should test your site and configure custom policies accordingly. 26 | 27 | ## Installation 28 | 29 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 30 | 31 | ``` 32 | npm install @apostrophecms/security-headers 33 | ``` 34 | 35 | ## Usage 36 | 37 | Activate the `@apostrophecms/security-headers` module in the project's `app.js` file: 38 | 39 | ```javascript 40 | require('apostrophe')({ 41 | shortName: 'my-project', 42 | modules: { 43 | '@apostrophecms/security-headers': {} 44 | } 45 | }); 46 | ``` 47 | 48 | The headers to be sent can be overriden by setting them as options to the module in the project-level `modules/@apostrophecms/security-headers/index.js` file: 49 | 50 | ```javascript 51 | // in modules/@apostrophecms/security-headers/index.js 52 | module.exports = { 53 | options: { 54 | 'X-Frame-Options': 'DENY' 55 | } 56 | }; 57 | ``` 58 | 59 | You can also disable a header entirely by setting the option for it to `false`. 60 | 61 | ### Guide to configuring headers 62 | 63 | Here are the headers that are sent by default, with their default values: 64 | 65 | ```javascript 66 | module.exports = { 67 | // in modules/@apostrophecms/security-headers/index.js 68 | options: { 69 | // 1 year. Do not include subdomains as they could be unrelated sites 70 | 'Strict-Transport-Security': 'max-age=31536000', 71 | // You may also set to DENY, however future Apostrophe modules may use 72 | // iframes to present previews etc. 73 | 'X-Frame-Options': 'SAMEORIGIN', 74 | // If you have issues with broken images etc., make sure content type 75 | // configuration is correct for your production server 76 | 'X-Content-Type-Options': 'nosniff', 77 | // Very new. Used to entirely disable browser features like geolocation per host. 78 | // Since we don't know what your site may need, we don't try to set this 79 | // header by default (false means "don't send the header") 80 | 'Permissions-Policy': false, 81 | // Don't send a "Referer" (sp) header unless the new URL shares the same 82 | // origin. You can set this to `false` if you prefer cross-origin "Referer" 83 | // headers be sent. Apostrophe does not rely on them 84 | 'Referrer-Policy': 'same-origin', 85 | // `true` means it should be computed according to the rules below. 86 | // You may also pass your own string, or `false` to not send this header. 87 | // The `policies` option and all of its sub-options are ignored unless 88 | // `Content-Security-Policy` is `true`. 89 | 'Content-Security-Policy': true 90 | } 91 | }; 92 | ``` 93 | 94 | For more information about these security headers see the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#security). 95 | 96 | ### Configuring the `Content-Security-Policy` header 97 | 98 | The `Content-Security-Policy` header is more complex than the others. As described [in MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy), this header is used to determine which hosts are permitted as a source for stylesheets, scripts, images and more. While you can certainly set a fixed value for it, the default response for it is the result of merging together options for individual use cases ("policies") as shown below. This makes it easier to think about what you need to allow for a particular purpose. The actual header sent contains all of the permissions required to satisfy all of the policies. 99 | 100 | The default configuration is shown below. You do **not** have to copy and paste the entire default configuration, only the policies you wish to change. Any policy you specify explicitly at project level overrides all of the default settings shown below for that 101 | policy. You may set one to `false` to completely disable it. You may also introduce entirely new policies. 102 | 103 | Policies of the same type from different sub-options are merged, with the largest set of keywords and hosts enabled. This is done because browsers do not support more than one style-src policy, for example, but do support specifying several hosts. 104 | 105 | Note the `HOSTS` wildcard which matches all expected hosts that Apostrophe is aware of, including `baseUrl` settings, CDN hosts and locale-specific hostnames. 106 | 107 | ```javascript 108 | module.exports = { 109 | // in modules/@apostrophecms/security-headers/index.js 110 | options: { 111 | policies: { 112 | general: { 113 | 'default-src': 'HOSTS', 114 | // Because it is necessary for some of the output of our rich text editor 115 | 'style-src': "HOSTS 'unsafe-inline'", 116 | 'script-src': 'HOSTS', 117 | 'font-src': 'HOSTS', 118 | 'img-src': 'HOSTS blob:', 119 | 'frame-src': "'self'" 120 | }, 121 | 122 | // Set this sub-option to false if you wish to forbid google fonts 123 | googleFonts: { 124 | 'style-src': 'fonts.googleapis.com', 125 | 'font-src': 'fonts.gstatic.com' 126 | }, 127 | 128 | // Set this sub-option to false if you do not use the video widget 129 | oembed: { 130 | 'frame-src': '*.youtube.com *.vimeo.com', 131 | 'img-src': '*.ytimg.com' 132 | }, 133 | 134 | // Set this sub-option to false if you do not wish to permit Google Analytics and 135 | // Google Adsense 136 | analytics: { 137 | 'default-src': '*.google-analytics.com *.doubleclick.net', 138 | // Note that use of google tag manager by definition brings in scripts from 139 | // more third party sites and you will need to add policies for them 140 | 'script-src': '*.google-analytics.com *.doubleclick.net *.googletagmanager.com', 141 | } 142 | } 143 | } 144 | }; 145 | ``` 146 | 147 | #### Inline style attributes are still allowed 148 | 149 | Note that `style-src` is set by default to permit inline style attributes. This is currently necessary because the output of the tiptap rich text editor used in Apostrophe involves inline 150 | styles in some cases. 151 | 152 | #### Inline script tags are **not** allowed 153 | 154 | Inline script tags (those without a `src`) are **not** allowed by the default policies shown above, as this is one of the primary benefits of using the `Content-Security-Policy` header. If you do choose to output an inline script tag, you may do so if you use the "nonce" template argument provided by this module, like this: 155 | 156 | ``` 157 | 160 | ``` 161 | 162 | The `nonce` template variable is always available in Nunjucks templates when using this module. It is generated uniquely for each new page request. 163 | 164 | The nonce mechanism ensures that the script tag was the intention of the developer and is not an XSS attack. However please note that you will lose the security benefits of this if you output other user-entered data inside the script tag without properly escaping it, for instance using the `| json` nunjucks filter. 165 | 166 | Setting the nonce attribute on a DOM element has no ill effects when this module is not in use, so it is OK to set it in inline script tags output by npm modules intended for use with or without this module. 167 | 168 | ### Custom policies 169 | 170 | You may add any number of custom policies. Any sub-option nested in your 171 | `policies` option is treated just like the standard cases above and merged into 172 | the final `Content-Security-Policy` header. 173 | 174 | ### Disabling standard policies 175 | 176 | You may set any of the standard policy sub-options above to `false` to disable them. 177 | 178 | ### Hosts wildcard 179 | 180 | Note that the `HOSTS` wildcard is automatically replaced with a list of hosts including any `baseUrl` host, localized hostnames for specific locales, CDN hosts from your uploadfs configuration, and `self`. Use of this wildcard is recommended as Apostrophe pushes assets to Amazon S3, CDNs, etc. when configured to do so, including scripts and stylesheets. 181 | 182 | You may override the normal list of hosts for `HOSTS` by setting the `legitimateHosts` option to an array of strings. You can also extend or override the `legitimateHosts` method of this module at project level. 183 | 184 | For example: 185 | 186 | ```javascript 187 | module.exports = { 188 | // in modules/@apostrophecms/security-headers/index.js 189 | options: { 190 | legitimateHosts: [ 'mysite.com', 'www.mysite.com', 'surprise.mysite.com' ] 191 | } 192 | }; 193 | ``` 194 | --------------------------------------------------------------------------------