Browse Source

feat(feature/security): add content security policy

integrate with docs site to block untrusted requests
master
Josh Habdas 2 years ago
parent
commit
de243545c4
Signed by untrusted user: jhabdas GPG Key ID: B148B31154C75A74
14 changed files with 282 additions and 129 deletions
  1. +5
    -0
      archetypes/search.md
  2. +1
    -0
      assets/js/baseof.js
  3. +119
    -0
      assets/js/search.js
  4. +2
    -0
      assets/js/vendor/fetch-inject.min.js
  5. +2
    -0
      bin/install
  6. +15
    -2
      docs/config.toml
  7. +78
    -0
      docs/content/feature/content-security-policy.md
  8. +5
    -0
      docs/content/search/_index.md
  9. +10
    -2
      layouts/_default/baseof.html
  10. +3
    -120
      layouts/_default/search.html
  11. +0
    -5
      layouts/partials/async-scripts.html
  12. +35
    -0
      layouts/partials/meta/content-security-policy.html
  13. +6
    -0
      package-lock.json
  14. +1
    -0
      package.json

+ 5
- 0
archetypes/search.md View File

@ -7,4 +7,9 @@ noindex = true # tell robots not to index
[form.input]
placeholder = "Enter search query…"
disabled = false
[security.csp.directives]
scriptSrc = [
"'sha512-Bxby9zhln4Zc2thGA1E9CdT4qcCY52SxO/SBxAH6qQK6LK6/1gGq1xJ3Uz0SXTsPSL6quze7bYQUHr94xJS7jQ=='",
"'unsafe-eval'"
]
+++

+ 1
- 0
assets/js/baseof.js View File

@ -0,0 +1 @@
fetchInject(["{{ "/js/lazysizes.min.js" | relURL }}"]);

+ 119
- 0
assets/js/search.js View File

@ -0,0 +1,119 @@
fetchInject([
"{{ "/js/vue.min.js" | relURL }}",
"{{ "/js/lodash.custom.min.js" | relURL }}",
"{{ "/js/fuse.min.js" | relURL }}",
"{{ "/js/mark.min.js" | relURL }}"
]).then(() => {
(function (window, document, undefined) {
'use strict';
const getQueryByParam = param => decodeURIComponent(
(location.search.split(param + '=')[1] || '').split('&')[0]
).replace(/\+/g, ' ');
const queryParam = 's';
const selectors = {
appContainer: '#search-app',
resultContainer: '#search-results',
searchInput: '#query'
};
const fuseOpts = {
shouldSort: true,
tokenize: true,
matchAllTokens: true,
includeScore: true,
includeMatches: true,
keys: [
{ name: "title", weight: 0.8 },
{ name: "contents", weight: 0.5 },
{ name: "tags", weight: 0.3 },
{ name: "categories", weight: 0.3 }
]
};
const getSearchInput = () => document.querySelector(selectors.searchInput);
const focusSearchInput = () => getSearchInput().focus();
const searchQuery = getSearchInput().value = getQueryByParam(queryParam);
const fuse = new Fuse([], fuseOpts);
window.fetch('/index.json').then(response => {
response.text().then(searchData => {
fuse.setCollection(JSON.parse(searchData));
searchQuery && search(searchQuery);
});
});
const getUrl = (query) => {
const encodedQuery = encodeURIComponent(query);
const url = "{{ .URL }}";
return (encodedQuery)
? `${url}?${queryParam}=${encodedQuery}`
: url;
};
let mark = new Mark(
document.querySelector(
selectors.resultContainer
)
);
const app = new Vue({
delimiters: ['{', '}'],
el: selectors.appContainer,
data: {
fuse: null,
results: [],
query: getQueryByParam(queryParam),
resultsForSearch: getQueryByParam(queryParam)
},
mounted () {
this.fuse = fuse;
window.onpopstate = (evt) => {
this.query = evt.state.query;
};
document.onkeyup = function (evt) {
evt.key === 's' && focusSearchInput();
}
focusSearchInput();
},
watch: {
query () {
this.executeSearch();
window.history.replaceState(
{query: this.query},
null,
getUrl(this.query)
);
}
},
beforeUpdate: function () {
mark.unmark();
},
updated: function () {
this.$nextTick(function () {
mark = new Mark(
document.querySelector(
selectors.resultContainer
)
)
mark.mark(this.query.trim());
})
},
methods: {
executeSearch: _.debounce(function () {
const trimmedQuery = this.query.trim();
this.resultsForSearch = trimmedQuery;
this.results = (trimmedQuery)
? this.fuse.search(trimmedQuery)
: [];
}, 250)
}
});
const search = query => {
app.results = fuse.search(query);
};
})(window, document);
});

+ 2
- 0
assets/js/vendor/fetch-inject.min.js View File

@ -0,0 +1,2 @@
/*! Fetch Inject v2.0.2 | Copyright (C) 2017–2018 Josh Habdas <jhabdas@protonmail.com> | @license Zlib */
var fetchInject=function(){"use strict";const e=function(e,t,r,n,o,c,i){c=t.createElement(r),i=t.getElementsByTagName(r)[0],c.appendChild(t.createTextNode(n.text)),c.onload=o(n),i?i.parentNode.insertBefore(c,i):t.head.appendChild(c)};return function(t,r){if(!arguments.length)return Promise.reject(new ReferenceError("Failed to execute 'fetchInject': 1 argument required but only 0 present."));if(arguments[0]&&arguments[0].constructor!==Array)return Promise.reject(new TypeError("Failed to execute 'fetchInject': argument 1 must be of type 'Array'."));if(arguments[1]&&arguments[1].constructor!==Promise)return Promise.reject(new TypeError("Failed to execute 'fetchInject': argument 2 must be of type 'Promise'."));const n=[],o=r?[].concat(r):[],c=[];return t.forEach(e=>o.push(window.fetch(e).then(e=>[e.clone().text(),e.blob()]).then(e=>Promise.all(e).then(e=>{n.push({text:e[0],blob:e[1]})})))),Promise.all(o).then(()=>(n.forEach(t=>{c.push({then:r=>{t.blob.type.includes("text/css")?e(window,document,"style",t,r):e(window,document,"script",t,r)}})}),Promise.all(c)))}}();

+ 2
- 0
bin/install View File

@ -62,6 +62,7 @@ theme = [
"after-dark"
]
disableLiveReload = false # Optional, set true to disable live reload
enableRobotsTXT = true # Suggested, enable robots.txt file
pygmentsCodefences = true # Suggested, highlight fenced code blocks
@ -75,6 +76,7 @@ footnoteReturnLinkContents = "↩" # Provides a nicer footnote return link
author = "" # Optional, controls author name display on posts
hide_author = false # Optional, set true to hide author name on posts
has_cookies = false # Optional, set true to disable cookie disclaimer
disable_csp = false # Optional, set true to disable content security policy
images = [
"https://source.unsplash.com/collection/983219/2000x1322"
] # Suggested, controls default Open Graph images

+ 15
- 2
docs/config.toml View File

@ -7,6 +7,7 @@ theme = [
"after-dark"
]
disableLiveReload = false # Optional, set true to disable live reload
enableRobotsTXT = true # Suggested, enable robots.txt file
pygmentsCodefences = true # Suggested, highlight fenced code blocks
@ -22,11 +23,15 @@ footnoteReturnLinkContents = "↩" # Provides a nicer footnote return link
languageCode = "id-ID" # Controls site language
languageName = "Indonesia"
[outputs]
home = ["HTML", "RSS", "JSON"]
[params]
description = "After Dark is free and open-source software you can use to create a website, blog or app." # Suggested, controls default description meta
author = "Josh Habdas" # Optional, controls author name display on posts
hide_author = true # Optional, set true to hide author name on posts
has_cookies = false # Optional, set true to disable cookie disclaimer
disable_csp = false # Optional, set true to disable content security policy
images = [
"https://after-dark.habd.as/images/screenshots/after-dark-v6.15.0-homepage-fs8.png"
] # Suggested, controls default Open Graph images
@ -34,8 +39,16 @@ footnoteReturnLinkContents = "↩" # Provides a nicer footnote return link
[params.layout.menu.main]
hidden = false # Optional, set false or remove to show section menu
[outputs]
home = ["HTML", "RSS", "JSON"]
[params.security.csp.directives]
scriptSrc = [
"'self'",
"'unsafe-inline'",
"https:",
"'sha512-Jx/MqTxYWqHdoOkHItRJJZCvFDhERPr5gG4I5ESu3V&#43;BgQyAQ6wXfdsGzhzmT0yyvkAWz2jbrn81q90RRJTSTg=='",
"'sha512-hno7WeTIciCJSjg/myjyK30HYkrcGCVwo4g4SpUalvrs3r2lS7bPNIQwbCNypKbg7BZ1sA4AsGnk6Gq4NOKpGA=='",
"'sha512-ISTAV0GadOIz/NXXHOS&#43;eCM0ysXVVHhQTlvA6LJxz/DeA5yIxm0Vqf5IE&#43;WH0yuuXkayAKtoZkQ326nch5f/fg=='",
"'strict-dynamic'"
]
[params.seo.webmaster_verifications]
yandex = "4006a8813cbb9fdb" # https://webmaster.yandex.com/

+ 78
- 0
docs/content/feature/content-security-policy.md View File

@ -0,0 +1,78 @@
+++
title = "Content Security Policy"
description = "Control resources the user agent is allowed to load."
categories = ["security"]
tags = ["privacy", "xss", "csp"]
features = ["code highlighter", "related content", "snippets"]
[blackfriday]
smartypants = false
+++
After Dark helps you secure your site using Content Security Policy (CSP). Basic CSP is enabled by default and [Advanced Configurations]({{< relref "#advanced-configuration" >}}) are also possible.
Basic CSP offers minimal protection but is still helpful as it keeps resources from accidentally being loaded over `http`. In addition, Basic CSP automatically blocks all Flash and Silverlight objects before they have a chance to load.
## Basic Configuration
Disable CSP site-wide add the following to your site config:
```toml
[params]
disable_csp = true # disable site-wide
```
Disable CSP for a specific page using front matter:
```toml
disable_csp = true # disable for page
```
## Advanced Configuration
Advanced configuration allows you to fine-tune policy directives based on your specific needs. To get started specify at least 1 CSP directive in site config.
{{% hackcss-alert type="info" %}}**Note:** You may wish to {{< external "https://gohugo.io/getting-started/usage/#disable-livereload" "Disable LiveReload" />}} while defining advanced config.{{% /hackcss-alert %}}
Define the {{< external "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src" "script-src" />}} directive site-wide:
```toml
[params.security.csp.directives]
scriptSrc = [
"'self'",
"'unsafe-inline'",
"https:",
"'sha512-Jx/MqTxYWqHdoOkHItRJJZCvFDhERPr5gG4I5ESu3V+BgQyAQ6wXfdsGzhzmT0yyvkAWz2jbrn81q90RRJTSTg=='",
"'sha512-hno7WeTIciCJSjg/myjyK30HYkrcGCVwo4g4SpUalvrs3r2lS7bPNIQwbCNypKbg7BZ1sA4AsGnk6Gq4NOKpGA=='",
"'sha512-ISTAV0GadOIz/NXXHOS&+eCM0ysXVVHhQTlvA6LJxz/DeA5yIxm0Vqf5IE&+WH0yuuXkayAKtoZkQ326nch5f/fg=='",
"'strict-dynamic'"
]
```
This will override the `script-src` directive's advanced default:
Directive | Mapping | Advanced Default
--- | --- | ---
default-src | defaultSrc | 'none'
connect-src | connectSrc | 'self'
font-src | fontSrc | 'self'
img-src | imgSrc | 'self' data:
script-src | scriptSrc | 'none'
style-src | styleSrc | 'self' 'unsafe-inline'
frame-src | frameSrc | 'self'
object-src | objectSrc | 'none'
Once set, view the `Content-Security-Policy` meta tag in the `head` of your page and inspect the console for errors as you navigate to different pages. Each time you encounter an error caused by CSP is an opportunity to improve your policy.
Continue overriding advanced defaults as necessary until all CSP errors are resolved or you're satisfied with the changes.
Add page-specific directives from page front matter using the same structure and naming conventions used in site config to append items for that page.
{{% hackcss-alert type="info" %}}**Note:** Page-specific directives _will not_ override any site-wide setting and will not apply without first overriding its advanced default via site config.{{% /hackcss-alert %}}
Repeat the above process for each of the available advanced defaults until you're satisfied with your overall policy. Leverage [Basic Configuration]({{< relref "#basic-configuration" >}}) to disable CSP on a per-page basis during testing.
## Additional Resources
- About {{< external "https://infosec.mozilla.org/guidelines/web_security#content-security-policy" "Content Security Policy" />}}
- Test policy with {{< external "https://observatory.mozilla.org" "Observatory by Mozilla" />}}
- See {{< external "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Examples" "CSP examples" />}} to learn more

+ 5
- 0
docs/content/search/_index.md View File

@ -7,4 +7,9 @@ noindex = true # tell robots not to index
[form.input]
placeholder = "Enter search query…"
disabled = false
[security.csp.directives]
scriptSrc = [
"'sha512-Bxby9zhln4Zc2thGA1E9CdT4qcCY52SxO/SBxAH6qQK6LK6/1gGq1xJ3Uz0SXTsPSL6quze7bYQUHr94xJS7jQ=='",
"'unsafe-eval'"
]
+++

+ 10
- 2
layouts/_default/baseof.html View File

@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
<head>
{{ partial "meta/content-security-policy.html" . }}
{{ .Hugo.Generator }}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -21,7 +22,14 @@
{{ partial "meta/verifications.html" . }}
{{ end }}
{{ partial "meta/http-referrer.html" . }}
{{ partial "async-scripts.html" . }}
{{ $import := resources.Get "/js/vendor/fetch-inject.min.js" }}
{{ $baseof := resources.Get "/js/baseof.js" | resources.ExecuteAsTemplate "baseof.js" . }}
<script integrity="{{ ($import | resources.Fingerprint "sha512").Data.Integrity }}">
{{- $import.Content | safeJS -}}
</script>
<script integrity="{{ ($baseof | resources.Fingerprint "sha512").Data.Integrity }}">
{{- $baseof.Content | safeJS -}}
</script>
{{ partial "head/modules.html" . }}
{{ if .RSSLink }}
<link href="{{ .RSSLink }}" rel="alternate" type="application/rss+xml" title="{{ .Site.Title }}">
@ -37,7 +45,7 @@
{{ partial "global-styles.html" . }}
{{ $highlights := findRE "class\\s*?=\\s*?\".*?\\bhighlight\\b.*?\"|class\\s*?=\\s*?highlight\\b" .Content }}
{{ if ge (len $highlights) 1 }}
<script>fetchInject([{{ "/css/syntax.css" | relURL }}])</script>
<script integrity="sha512-ISTAV0GadOIz/NXXHOS+eCM0ysXVVHhQTlvA6LJxz/DeA5yIxm0Vqf5IE+WH0yuuXkayAKtoZkQ326nch5f/fg==">fetchInject([{{ "/css/syntax.css" | relURL }}]);</script>
<noscript>
<link href="{{ "/css/syntax.css" | relURL }}" rel="stylesheet">
</noscript>

+ 3
- 120
layouts/_default/search.html View File

@ -41,125 +41,8 @@
{{ define "footer" }}
{{ partial "powered-by.html" . }}
{{ partial "cookie-disclaimer.html" . }}
<script>
fetchInject([
{{ "/js/vue.min.js" | relURL }},
{{ "/js/lodash.custom.min.js" | relURL }},
{{ "/js/fuse.min.js" | relURL }},
{{ "/js/mark.min.js" | relURL }}
]).then(() => {
(function (window, document, undefined) {
"use strict";
const getQueryByParam = param => decodeURIComponent(
(location.search.split(param + '=')[1] || '').split('&')[0]
).replace(/\+/g, ' ');
const queryParam = 's';
const selectors = {
appContainer: '#search-app',
resultContainer: '#search-results',
searchInput: '#query'
};
const fuseOpts = {
shouldSort: true,
tokenize: true,
matchAllTokens: true,
includeScore: true,
includeMatches: true,
keys: [
{ name: "title", weight: 0.8 },
{ name: "contents", weight: 0.5 },
{ name: "tags", weight: 0.3 },
{ name: "categories", weight: 0.3 }
]
};
const getSearchInput = () => document.querySelector(selectors.searchInput);
const focusSearchInput = () => getSearchInput().focus();
const searchQuery = getSearchInput().value = getQueryByParam(queryParam);
const fuse = new Fuse([], fuseOpts);
window.fetch('/index.json').then(response => {
response.text().then(searchData => {
fuse.setCollection(JSON.parse(searchData));
searchQuery && search(searchQuery);
});
});
const getUrl = (query) => {
const encodedQuery = encodeURIComponent(query);
const url = {{ .URL }};
return (encodedQuery)
? `${url}?${queryParam}=${encodedQuery}`
: url;
};
let mark = new Mark(
document.querySelector(
selectors.resultContainer
)
);
const app = new Vue({
delimiters: ['{', '}'],
el: selectors.appContainer,
data: {
fuse: null,
results: [],
query: getQueryByParam(queryParam),
resultsForSearch: getQueryByParam(queryParam)
},
mounted () {
this.fuse = fuse;
window.onpopstate = (evt) => {
this.query = evt.state.query;
};
document.onkeyup = function (evt) {
evt.key === 's' && focusSearchInput();
}
focusSearchInput();
},
watch: {
query () {
this.executeSearch();
window.history.replaceState(
{query: this.query},
null,
getUrl(this.query)
);
}
},
beforeUpdate: function () {
mark.unmark();
},
updated: function () {
this.$nextTick(function () {
mark = new Mark(
document.querySelector(
selectors.resultContainer
)
)
mark.mark(this.query.trim());
})
},
methods: {
executeSearch: _.debounce(function () {
const trimmedQuery = this.query.trim();
this.resultsForSearch = trimmedQuery;
this.results = (trimmedQuery)
? this.fuse.search(trimmedQuery)
: [];
}, 250)
}
});
const search = query => {
app.results = fuse.search(query);
};
})(window, document);
});
{{ $script := resources.Get "/js/search.js" | resources.ExecuteAsTemplate "search.js" . }}
<script integrity="{{ printf "%s" ($script | resources.Fingerprint "sha512").Data.Integrity | safeHTML }}">
{{- $script.Content | safeJS -}}
</script>
{{ end }}

+ 0
- 5
layouts/partials/async-scripts.html View File

@ -1,5 +0,0 @@
<script>
/*! Fetch Inject v2.0.2 | Copyright (C) 2017–2018 Josh Habdas <jhabdas@protonmail.com> | @license Zlib */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.fetchInject=t()}(this,function(){"use strict";const e=function(e,t,n,r,o,c,i){c=t.createElement(n),i=t.getElementsByTagName(n)[0],c.appendChild(t.createTextNode(r.text)),c.onload=o(r),i?i.parentNode.insertBefore(c,i):t.head.appendChild(c)};return function(t,n){if(!arguments.length)return Promise.reject(new ReferenceError("Failed to execute 'fetchInject': 1 argument required but only 0 present."));if(arguments[0]&&arguments[0].constructor!==Array)return Promise.reject(new TypeError("Failed to execute 'fetchInject': argument 1 must be of type 'Array'."));if(arguments[1]&&arguments[1].constructor!==Promise)return Promise.reject(new TypeError("Failed to execute 'fetchInject': argument 2 must be of type 'Promise'."));const r=[],o=n?[].concat(n):[],c=[];return t.forEach(e=>o.push(window.fetch(e).then(e=>[e.clone().text(),e.blob()]).then(e=>Promise.all(e).then(e=>{r.push({text:e[0],blob:e[1]})})))),Promise.all(o).then(()=>(r.forEach(t=>{c.push({then:n=>{t.blob.type.includes("text/css")?e(window,document,"style",t,n):e(window,document,"script",t,n)}})}),Promise.all(c)))}});
fetchInject([{{ "/js/lazysizes.min.js" | relURL }}]);
</script>

+ 35
- 0
layouts/partials/meta/content-security-policy.html View File

@ -0,0 +1,35 @@
{{ $is_disabled := .Param "disable_csp" }}
{{ $site_directives := .Site.Params.security.csp.directives }}
{{ if and $site_directives (ne $is_disabled true) }}
{{ if ge (len $site_directives) 1 }}
<meta
http-equiv="Content-Security-Policy"
content="default-src{{ if $site_directives.defaultsrc }}
{{- range $site_directives.defaultsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.defaultsrc }} {{ . | safeHTML }}{{ end -}}
{{ else }} 'none'{{ end }};connect-src{{ if $site_directives.connectsrc }}
{{- range $site_directives.connectsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.connectsrc }} {{ . | safeHTML }}{{ end -}}
{{ else }} 'self'{{ end }};font-src{{ if $site_directives.fontsrc }}
{{- range $site_directives.fontsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.fontsrc }} {{ . | safeHTML }}{{ end -}}
{{ else }} 'self'{{ end }};img-src{{ if $site_directives.imgsrc }}
{{- range $site_directives.imgsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.imgsrc }} {{ . | safeHTML }}{{ end -}}
{{ else }} 'self' data:{{ end }};script-src{{ if $site_directives.scriptsrc }}
{{- range $site_directives.scriptsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.scriptsrc }} {{ . | safeHTML }} {{ end -}}
{{ else }} 'none'{{ end }};style-src{{ if $site_directives.stylesrc }}
{{- range $site_directives.stylesrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.stylesrc }} {{ . | safeHTML }} {{ end -}}
{{ else }} 'self' 'unsafe-inline'{{ end }};frame-src{{ if $site_directives.framesrc }}
{{- range $site_directives.framesrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.framesrc }} {{ . | safeHTML }} {{ end -}}
{{ else }} 'self'{{ end }};object-src{{ if $site_directives.objectsrc }}
{{- range $site_directives.objectsrc }} {{ . | safeHTML }}{{ end -}}
{{- range .Params.security.csp.directives.objectsrc }} {{ . | safeHTML }} {{ end -}}
{{ else }} 'none'{{ end }};">
{{ end }}
{{ else if not $is_disabled }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self' https: 'unsafe-inline' 'unsafe-eval'; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'">
{{ end }}

+ 6
- 0
package-lock.json View File

@ -565,6 +565,12 @@
"strip-eof": "^1.0.0"
}
},
"fetch-inject": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fetch-inject/-/fetch-inject-2.0.2.tgz",
"integrity": "sha512-oTvEhS2A5oN/GeI2yliXdanAVZD+CyNveOgv4Aa6itoybJdLCJglYhJiOQ6AMUXxyiUNU/DnlcQQcNjvvySNlg==",
"dev": true
},
"figures": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz",

+ 1
- 0
package.json View File

@ -35,6 +35,7 @@
},
"devDependencies": {
"atom-one-chroma": "^2.0.0",
"fetch-inject": "^2.0.2",
"fuse.js": "^3.3.0",
"hack": "^0.8.1",
"lazysizes": "^4.1.4",

Loading…
Cancel
Save