new file mode 100644
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: MIT
+#
+# error-reporting-tool - middleware
+#
+# Licensed under the MIT license, see COPYING.MIT for details
+
+from django.conf import settings
+
+
+class ContentSecurityPolicyMiddleware:
+ """Add a Content-Security-Policy header to every response.
+
+ The policy is read from settings.CONTENT_SECURITY_POLICY, a dict mapping
+ each directive to a list of sources (an empty list gives a directive with
+ no value, e.g. 'upgrade-insecure-requests'). When
+ settings.CONTENT_SECURITY_POLICY_REPORT_ONLY is True the policy is sent
+ as Content-Security-Policy-Report-Only instead, so violations are
+ reported by browsers but not blocked.
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ policy = getattr(settings, 'CONTENT_SECURITY_POLICY', None) or {}
+ self.policy = "; ".join(
+ " ".join([directive] + list(sources))
+ for directive, sources in policy.items())
+
+ if getattr(settings, 'CONTENT_SECURITY_POLICY_REPORT_ONLY', False):
+ self.header = 'Content-Security-Policy-Report-Only'
+ else:
+ self.header = 'Content-Security-Policy'
+
+ def __call__(self, request):
+ response = self.get_response(request)
+
+ # Leave alone any policy a view has set itself
+ if self.policy and self.header not in response:
+ response[self.header] = self.policy
+
+ return response
@@ -129,3 +129,31 @@ th a, th span {
word-wrap: break-word;
word-break: break-all;
}
+
+/* Styles below replace inline style attributes, which the Content
+ * Security Policy (style-src 'self') does not allow.
+ */
+.filter.visible, .sorting-arrows.visible {
+ visibility: visible;
+}
+
+.back-btn {
+ margin-top: 7px;
+}
+
+.details-title {
+ margin-left: 60px;
+}
+
+.pagesize-label {
+ padding-top: 5px;
+}
+
+select.pagesize {
+ margin-top: 5px;
+ margin-bottom: 0px;
+}
+
+.chart-svg {
+ height: 400px;
+}
@@ -42,8 +42,67 @@ function dumpsUrlParams(obj) {
return str.substr(0,str.length-1);
};
+/* Escape text for use in HTML strings, e.g. nvd3 tooltips which are
+ * inserted with innerHTML */
+function escapeHtml(text) {
+ return $('<div/>').text(String(text)).html();
+}
+
+/* Draw a statistics bar chart from a templates/discretebarchart.html
+ * fragment. The data is embedded as a JSON script element rather than
+ * inline JavaScript so that it works under the Content Security Policy.
+ */
+function renderDiscreteBarChart(container) {
+ var data = JSON.parse(container.find('script[type="application/json"]').text());
+ var filterUrl = container.data('filter-url');
+ var filterType = container.data('filter-type');
+
+ nv.addGraph(function() {
+ var chart = nv.models.discreteBarChart();
+
+ chart.staggerLabels(true);
+ chart.yAxis.tickFormat(d3.format(',.0f'));
+ /* Ellipsize long labels */
+ chart.xAxis.tickFormat(function(label) {
+ if (label.length >= 14)
+ return label.substring(0,13) + '\u2026';
+
+ return label;
+ });
+
+ chart.tooltipContent(function(key, x, y, e) {
+ return escapeHtml(e.point.x) + ": " + escapeHtml(e.point.y);
+ });
+
+ chart.discretebar.dispatch.on('elementClick', function(e) {
+ window.location.href = filterUrl + '?filter=' +
+ encodeURIComponent(e.point.x) + '&type=' + encodeURIComponent(filterType);
+ });
+
+ /* TODO nv.utils.windowResize(chart.update); */
+
+ d3.select(container.find('svg')[0])
+ .datum(data)
+ .transition(350)
+ .attr('height', 400)
+ .call(chart);
+ });
+}
+
$(document).ready(function(){
+ /* Load the statistics charts */
+ $("[data-stats-url]").each(function () {
+ var thumbnail = $(this);
+ $.get(thumbnail.data('stats-url'), function (html) {
+ var chart = $(html).filter('.discrete-bar-chart');
+ if (chart.length == 0)
+ return;
+ thumbnail.append(chart);
+ renderDiscreteBarChart(chart);
+ });
+ });
+
/* Use json encoding for cookie content */
$.cookie.json = true;
@@ -2,7 +2,7 @@ import unittest
import urllib.request, urllib.parse, urllib.error
import json
import re
-from django.test import Client
+from django.test import Client, override_settings
from Post.models import BuildFailure, Build
#Delete the data between tests
@@ -241,3 +241,62 @@ class SimpleTest(unittest.TestCase):
response = self.client.get("/Errors/Details/9898989898/")
self.assertEqual(response.status_code, 200)
+
+class CSPTest(unittest.TestCase):
+ def setUp(self):
+ self.client = Client(HTTP_HOST="testhost")
+
+ def test_csp_header(self):
+ for url in ('/Statistics/', '/Errors/Latest/'):
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ csp = response['Content-Security-Policy']
+ self.assertIn("script-src 'self'", csp)
+ self.assertIn("style-src 'self'", csp)
+ self.assertIn("object-src 'none'", csp)
+ self.assertNotIn("unsafe-inline", csp)
+ self.assertNotIn("unsafe-eval", csp)
+
+ def test_csp_report_only(self):
+ with override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=True):
+ # New client so the middleware picks up the setting
+ response = Client(HTTP_HOST="testhost").get('/Statistics/')
+ self.assertIn('Content-Security-Policy-Report-Only', response)
+ self.assertNotIn('Content-Security-Policy', response)
+
+ def test_csp_disabled(self):
+ with override_settings(CONTENT_SECURITY_POLICY=None):
+ response = Client(HTTP_HOST="testhost").get('/Statistics/')
+ self.assertNotIn('Content-Security-Policy', response)
+
+ def test_no_inline_script_or_style(self):
+ Build.objects.all().delete()
+
+ with open("test-data/test-payload.json") as f:
+ data = urllib.parse.urlencode({'data': f.read()})
+ response = self.client.post("/ClientPost/", data, "application/json")
+ self.assertEqual(response.status_code, 200)
+
+ build = Build.objects.get()
+ failure = BuildFailure.objects.get()
+ urls = ['/Statistics/',
+ '/Errors/Statistics/MACHINE/',
+ '/Errors/Latest/',
+ '/Errors/Build/%d/' % build.id,
+ '/Errors/Details/%d/' % failure.id]
+
+ for url in urls:
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode('utf-8')
+ self.assertIsNone(re.search(r'\sstyle\s*=', content), url)
+ # Only external scripts and non-executed JSON data are allowed
+ for script in re.findall(r'<script[^>]*>', content):
+ self.assertTrue(re.search(r'\ssrc\s*=', script) or
+ 'type="application/json"' in script,
+ "%s: %s" % (url, script))
+
+ response = self.client.get('/Errors/Statistics/MACHINE/')
+ self.assertIn(build.MACHINE, response.content.decode('utf-8'))
+
+ Build.objects.all().delete()
@@ -296,11 +296,9 @@ def chart(request, template_name, key):
if (alldata == {}):
return HttpResponse("")
- data = json.dumps([{ 'values': list(alldata)}])
- # Replace MACHINE with x and dcount with value
- # We do this in the string as it's more efficient
- data = data.replace(key, "x")
- data = data.replace("dcount", "y")
+ # Rename e.g. MACHINE to x and dcount to y for nvd3
+ data = [{ 'values': [{ 'x': item[key], 'y': item['dcount'] }
+ for item in alldata] }]
context = { 'data' : data,
'chart_id': key,
@@ -38,6 +38,41 @@ In order to send an error report to the server, run the "send-error-report file_
For more information on error reporting and log collection see: http://www.yoctoproject.org/docs/current/dev-manual/dev-manual.html#using-the-error-reporting-tool
+Content Security Policy
+-----------------------
+
+Every response carries a Content-Security-Policy header, added by
+Post.middleware.ContentSecurityPolicyMiddleware. The default policy in
+settings.py only allows resources served by the application itself:
+
+ default-src 'self'; script-src 'self'; style-src 'self';
+ img-src 'self' data:; font-src 'self'; connect-src 'self';
+ object-src 'none'; base-uri 'self'; form-action 'self';
+ frame-ancestors 'none'
+
+Inline scripts, inline event handlers (onclick=...), style attributes and
+eval() are blocked. When changing templates put JavaScript in
+Post/static/js/main.js and CSS in Post/static/css/custom.css. To pass data
+from a view to JavaScript use the json_script template filter or data-*
+attributes.
+
+The policy is configured with CONTENT_SECURITY_POLICY, a dict mapping each
+directive to a list of sources. For example, to also load images from a CDN
+and have browsers report violations:
+
+ CONTENT_SECURITY_POLICY['img-src'] = ["'self'", "data:", "https://cdn.example.com"]
+ CONTENT_SECURITY_POLICY['report-uri'] = ["https://example.com/csp-report"]
+
+Set CONTENT_SECURITY_POLICY = None to disable the header. Set
+CONTENT_SECURITY_POLICY_REPORT_ONLY = True to send the policy as
+Content-Security-Policy-Report-Only, so violations are reported but not
+blocked; this is useful when trying out a policy change. With the docker
+setup set CSP_REPORT_ONLY=1 in .env to do the same.
+
+If the web server in front of Django (Apache, nginx) also sets a
+Content-Security-Policy header, browsers enforce both, so set it in one place.
+The Django admin pages may lose some minor cosmetic styling under this policy.
+
Maintenance
-----------
@@ -48,6 +48,28 @@ MAX_UPLOAD_SIZE = "5242880"
# Tolerance value to determine the distance between similar errors
SIMILAR_FAILURE_DISTANCE = 10
+# Content Security Policy sent with every response by
+# Post.middleware.ContentSecurityPolicyMiddleware. Each directive maps to a
+# list of sources. All scripts, styles, fonts and images are served locally,
+# so no inline scripts or style attributes are allowed. Set to None to
+# disable the header.
+CONTENT_SECURITY_POLICY = {
+ 'default-src': ["'self'"],
+ 'script-src': ["'self'"],
+ 'style-src': ["'self'"],
+ 'img-src': ["'self'", "data:"],
+ 'font-src': ["'self'"],
+ 'connect-src': ["'self'"],
+ 'object-src': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+}
+
+# Send the policy as Content-Security-Policy-Report-Only so violations are
+# reported by browsers but not blocked. Useful when changing the policy.
+CONTENT_SECURITY_POLICY_REPORT_ONLY = False
+
# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
@@ -120,6 +142,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
+ 'Post.middleware.ContentSecurityPolicyMiddleware',
# Uncomment the next line for simple clickjacking protection:
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@@ -42,6 +42,28 @@ MAX_UPLOAD_SIZE = "5242880"
# Tolerance value to determine the distance between similar errors
SIMILAR_FAILURE_DISTANCE = 10
+# Content Security Policy sent with every response by
+# Post.middleware.ContentSecurityPolicyMiddleware. Each directive maps to a
+# list of sources. All scripts, styles, fonts and images are served locally,
+# so no inline scripts or style attributes are allowed. Set to None to
+# disable the header.
+CONTENT_SECURITY_POLICY = {
+ 'default-src': ["'self'"],
+ 'script-src': ["'self'"],
+ 'style-src': ["'self'"],
+ 'img-src': ["'self'", "data:"],
+ 'font-src': ["'self'"],
+ 'connect-src': ["'self'"],
+ 'object-src': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+}
+
+# Send the policy as Content-Security-Policy-Report-Only so violations are
+# reported by browsers but not blocked. Useful when changing the policy.
+CONTENT_SECURITY_POLICY_REPORT_ONLY = os.environ.get('CSP_REPORT_ONLY', '').lower() in ('1', 'true', 'yes')
+
# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
@@ -114,6 +136,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
+ 'Post.middleware.ContentSecurityPolicyMiddleware',
# Uncomment the next line for simple clickjacking protection:
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@@ -1,39 +1,10 @@
-<div id="{{ chart_id }}"><svg style="height:400px;"></svg></div>
-<script type="text/javascript">
-
-var data_{{ chart_id }}=JSON.parse("{{ data|escapejs }}");
-
-nv.addGraph(function() {
- var chart = nv.models.discreteBarChart();
-
- chart.staggerLabels(true);
- chart.yAxis.tickFormat(d3.format(',.0f'));
- /* Ellipsize long labels */
- chart.xAxis.tickFormat(function(label) {
- if (label.length >= 14)
- return label.tooltip = label.substring(0,13) + '…';
-
- return label
- });
-
- chart.tooltipContent(function(key, y, e, graph) {
- var x = String(graph.point.x);
- var y = String(graph.point.y);
-
- tooltip_str = x + ": " +y;
- d3.selectAll('.discreteBar').on('click', function() {
- window.location.href='{% url 'latest_errors' %}?filter='+x+'&type={{chart_id|lower}}';
- });
- return tooltip_str;
- });
-
- /* TODO nv.utils.windowResize(chart.update); */
-
- d3.select('#{{ chart_id }} svg')
- .datum(data_{{ chart_id }})
- .transition(350)
- .attr('height', 400)
- .call(chart);
-
- });
-</script>
+{% comment %}
+Rendered by Post/static/js/main.js (renderDiscreteBarChart). No inline
+script here: the Content Security Policy only allows scripts from 'self'.
+{% endcomment %}
+<div class="discrete-bar-chart"
+ data-filter-url="{% url 'latest_errors' %}"
+ data-filter-type="{{ chart_id|lower }}">
+ {{ data|json_script }}
+ <svg class="chart-svg"></svg>
+</div>
@@ -6,10 +6,10 @@
<div class="alert"><p>Build id not found</p></div>
{% else %}
<div class="page-header">
- <a class="btn pull-left back-btn" style="margin-top:7px;" href="#">
+ <a class="btn pull-left back-btn" href="#">
<i class="icon-arrow-left"></i>
</a>
- <h1 style="margin-left:60px;">
+ <h1 class="details-title">
{% if detail.BUILD.ERROR_TYPE == error_types.RECIPE %}
{{detail.RECIPE}}-{{detail.RECIPE_VERSION}}
{% endif %}
@@ -8,13 +8,13 @@
<ul class="thumbnails">
<li class="span6">
<h2>By machine</h2>
- <div class="thumbnail" id="MACHINE">
+ <div class="thumbnail" id="MACHINE" data-stats-url="{% url "statistics" "MACHINE" %}">
</div>
</li>
<li class="span6">
<h2>By recipe</h2>
- <div class="thumbnail" id="RECIPE">
+ <div class="thumbnail" id="RECIPE" data-stats-url="{% url "statistics" "RECIPE" %}">
</div>
</li>
</ul>
@@ -23,12 +23,12 @@
<ul class="thumbnails">
<li class="span6">
<h2>By target</h2>
- <div class="thumbnail" id="TARGET">
+ <div class="thumbnail" id="TARGET" data-stats-url="{% url "statistics" "TARGET" %}">
</div>
</li>
<li class="span6">
<h2>By distro</h2>
- <div class="thumbnail" id="DISTRO">
+ <div class="thumbnail" id="DISTRO" data-stats-url="{% url "statistics" "DISTRO" %}">
</div>
</li>
</ul>
@@ -37,12 +37,12 @@
<ul class="thumbnails">
<li class="span6">
<h2>By branch</h2>
- <div class="thumbnail" id="BRANCH">
+ <div class="thumbnail" id="BRANCH" data-stats-url="{% url "statistics" "BRANCH" %}">
</div>
</li>
<li class="span6">
<h2>By commit</h2>
- <div class="thumbnail" id="COMMIT">
+ <div class="thumbnail" id="COMMIT" data-stats-url="{% url "statistics" "COMMIT" %}">
</div>
</li>
</ul>
@@ -51,52 +51,16 @@
<ul class="thumbnails">
<li class="span6">
<h2>By target system</h2>
- <div class="thumbnail" id="TARGET_SYS">
+ <div class="thumbnail" id="TARGET_SYS" data-stats-url="{% url "statistics" "TARGET_SYS" %}">
</div>
</li>
<li class="span6">
<h2>By host distro</h2>
- <div class="thumbnail" id="NATIVELSBSTRING">
+ <div class="thumbnail" id="NATIVELSBSTRING" data-stats-url="{% url "statistics" "NATIVELSBSTRING" %}">
</div>
</li>
</ul>
</div>
- <script type="text/javascript">
- $(document).ready(function () {
- $.get('{% url "statistics" "MACHINE" %}', function(data){
- $("#MACHINE").append (data);
- });
-
- $.get('{% url "statistics" "RECIPE" %}', function(data){
- $("#RECIPE").append (data);
- });
-
- $.get('{% url "statistics" "TARGET" %}', function(data){
- $("#TARGET").append (data);
- });
-
- $.get('{% url "statistics" "DISTRO" %}', function(data){
- $("#DISTRO").append (data);
- });
-
- $.get('{% url "statistics" "BRANCH" %}', function(data){
- $("#BRANCH").append (data);
- });
-
- $.get('{% url "statistics" "COMMIT" %}', function(data){
- $("#COMMIT").append (data);
- });
-
- $.get('{% url "statistics" "TARGET_SYS" %}', function(data){
- $("#TARGET_SYS").append (data);
- });
-
- $.get('{% url "statistics" "NATIVELSBSTRING" %}', function(data){
- $("#NATIVELSBSTRING").append (data);
- });
-
- }); /* End onLoad */
- </script>
</div>
</div> <!-- end of tab-content -->
@@ -40,8 +40,8 @@
{% endif %}
<div class="pull-right">
- <span class="help-inline" style="padding-top:5px;">Show rows: </span>
- <select style="margin-top:5px;margin-bottom:0px;" class="input-mini pagesize">
+ <span class="help-inline pagesize-label">Show rows: </span>
+ <select class="input-mini pagesize">
{% with "10 25 50 100 150" as list%}
{% for i in list.split %}
{% if request.session.limit == i|add:0 %}
@@ -93,10 +93,10 @@
{% if request.GET.order_by == "-"|add:col.field %}
<a href="#" class="sort-col sorted" data-order-by="{{col.field}}" >{{col.name}}</a>
- <span class="sorting-arrows" style="visibility: visible">▲</span>
+ <span class="sorting-arrows visible">▲</span>
{% else %}
<a href="#" class="sort-col sorted" data-order-by="-{{col.field}}" >{{col.name}}</a>
- <span class="sorting-arrows" style="visibility: visible">▼</span>
+ <span class="sorting-arrows visible">▼</span>
{% endif %}
{% else %}
{# default case is sorted by submitted_on #}
@@ -105,7 +105,7 @@
{% if col.clclass == "submitted_on" and not request.GET.order_by %}
<a href="#" class="sort-col sorted" data-order-by="-{{col.field}}" >{{col.name}}</a>
- <span class="sorting-arrows" style="visibility: visible">▼</span>
+ <span class="sorting-arrows visible">▼</span>
{% elif col.clclass == "failure" %}
{{col.name}}
@@ -192,7 +192,7 @@
<div class="btn" rel="popover"
data-content='
{{ build_fail.BUILD.COMMIT|escape}}
- <p><a href="#" class="filter" style="visibility: visible" data-filter="{{build_fail.BUILD.COMMIT}}" data-type="commit" >Filter by commit</a>'
+ <p><a href="#" class="filter visible" data-filter="{{build_fail.BUILD.COMMIT}}" data-type="commit" >Filter by commit</a>'
data-html="true"
>
{{ build_fail.BUILD.COMMIT|truncatechars:10}}
@@ -240,8 +240,8 @@
{%endif%}
</ul>
<div class="pull-right">
- <span class="help-inline" style="padding-top:5px;">Show rows:</span>
- <select style="margin-top:5px;margin-bottom:0px;" class="input-mini pagesize">
+ <span class="help-inline pagesize-label">Show rows:</span>
+ <select class="input-mini pagesize">
{% with "10 25 50 100 150" as list%}
{% for i in list.split %}
{% if request.session.limit == i|add:0 %}
@@ -16,12 +16,3 @@
<p>{% trans "Forgot password" %}? <a href="{% url "auth_password_reset" %}">{% trans "Reset it" %}</a>!</p>
<p>{% trans "Don't have an account" %}? <a href="{% url "registration_register" %}">{% trans "Create one now" %}</a>!</p>
{% endblock %}
-
-
-{% block scripts %}
-<script>
- $(document).ready(function() {
- $("#login_form input:text, #login_form textarea").first().focus();
- });
-</script>
-{% endblock %}
@@ -19,12 +19,3 @@
{% endif %}
{% endblock %}
-
-
-{% block scripts %}
-<script>
- $(document).ready(function() {
- $("#password_form input:text, #password_form textarea").first().focus();
- });
-</script>
-{% endblock %}
@@ -12,12 +12,3 @@
{% csrf_token %}
</form>
{% endblock %}
-
-
-{% block scripts %}
-<script>
- $(document).ready(function() {
- $("#password_form input:text, #password_form textarea").first().focus();
- });
-</script>
-{% endblock %}
@@ -13,12 +13,3 @@
{% csrf_token %}
</form>
{% endblock %}
-
-
-{% block scripts %}
-<script>
- $(document).ready(function() {
- $("#registration_form input:text, #registration_form textarea").first().focus();
- });
-</script>
-{% endblock %}