From 3810b0db685342de1282d5bf54bb099d2460fed9 Mon Sep 17 00:00:00 2001 From: Markus Willman <mpewil@utu.fi> Date: Sat, 10 Mar 2018 10:23:04 +0200 Subject: [PATCH] fix issues from ICT showroom - use a strict CSP (that actually works), blocks youtube media etc. - clock updates less frequently save on CPU cycles - fix frame cover height in kiosk mode --- server/app.py | 18 ++++++++++++++++-- server/dataHandler.py | 6 +++++- templates/feedback/index.html | 6 +++--- templates/foli/app.html | 2 +- templates/foli/index.html | 2 +- templates/index.html | 2 +- templates/info.html | 16 ++++++++++++---- templates/internal/base.html | 34 +++++++++++++++++++--------------- templates/map.html | 12 +++++++----- 9 files changed, 65 insertions(+), 33 deletions(-) diff --git a/server/app.py b/server/app.py index abaa15b..88ee923 100644 --- a/server/app.py +++ b/server/app.py @@ -9,7 +9,7 @@ from datetime import timedelta from dateutil.tz import tzlocal from .staticVariables import TEMPLATE_DIR, STATIC_DIR, DATA_DIR, DEFAULT_STOP, DEFAULT_HOST, DEFAULT_PORT, APP_VERSION, AVAILABLE_LANGUAGES, TRANSLATION_DIR, GOOGLE_MAPS_KEY, GOOGLE_MAPS_TEST_KEY, GOOGLE_MAPS_REGION -from .dataHandler import loadJson, hasTemplate, hasData, isDirectory, CustomJsonEncoder +from .dataHandler import loadJson, hasTemplate, hasData, isDirectory, CustomJsonEncoder, generateToken from .gtfs import GtfsHandler, GtfsConstants from .locator import FoliLocator @@ -91,11 +91,25 @@ def afterRequestHandler(response): response.headers['Server'] = APP_VERSION response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' - response.headers['Content-Security-Policy'] = "default-src='self'" + + # include a strict'ish list of dependencies, deliberately don't allow media sources such as youtube, this allows safer use of the twitter widget (assumes modern browser) + cspSrcWhitelist = "*.twitter.com *.twimg.com *.googleapis.com script.google.com script.googleusercontent.com fonts.gstatic.com maxcdn.bootstrapcdn.com cdnjs.cloudflare.com code.jquery.com www.feedrapp.info weatherwidget.io forecast7.com" + if not getattr(g, 'is_kiosk', False): + cspSrcWhitelist += " *.youtube.com" + + cspPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval' {whitelist}; img-src http: https: data:; base-uri 'none'; object-src 'none'; script-src 'self' 'nonce-{nonce}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' {whitelist}".format(nonce = g.csp_nonce, whitelist = cspSrcWhitelist) + if getattr(g, 'is_kiosk', False): + cspPolicy += "; media-src 'none'" + else: + cspPolicy += "; media-src *" + + response.headers['Content-Security-Policy'] = cspPolicy return response @app.url_value_preprocessor def pullUrlGlobals(endpoint, values): + g.csp_nonce = generateToken(16) + if endpoint == None or values == None or endpoint == 'static' or endpoint.startswith('root/'): return diff --git a/server/dataHandler.py b/server/dataHandler.py index d06edf8..2c7b671 100644 --- a/server/dataHandler.py +++ b/server/dataHandler.py @@ -1,6 +1,6 @@ # coding=utf-8 -import os, json, sys +import os, json, sys, base64 from flask.json import JSONEncoder from datetime import date @@ -61,3 +61,7 @@ def hasTemplate(filename): def hasData(filename): path = os.path.join(DATA_DIR, filename) return not os.path.islink(path) and os.path.isfile(os.path.normpath(path)) + +# python token_urlsafe backported +def generateToken(bytes): + return base64.urlsafe_b64encode(os.urandom(bytes)).rstrip(b'=').decode('ascii') diff --git a/templates/feedback/index.html b/templates/feedback/index.html index 8faf347..eeb2556 100644 --- a/templates/feedback/index.html +++ b/templates/feedback/index.html @@ -204,10 +204,10 @@ {% block scripts %} {{- super() }} - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" - integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> - <script> + <script nonce="{{ g.csp_nonce }}"> $(document).ready(function () { // this may require a GET request, because of browser security rules... // POST would be ideal, however, since we are only sending numeric values it doesn't really matter diff --git a/templates/foli/app.html b/templates/foli/app.html index 19d30be..4491c79 100644 --- a/templates/foli/app.html +++ b/templates/foli/app.html @@ -15,7 +15,7 @@ <div class="card-header"> <h3 class="card-title">Bussilippu kännykällä</h3> </div> - <div class="row" style="margin-top: 25px;"> + <div class="row" class="mt-3"> <div class="col-sm-5"> <img class="app-image" src="{{ util.static_url('img/mobiililippu_1.png') }}"> </div> diff --git a/templates/foli/index.html b/templates/foli/index.html index be6aead..950d0d8 100644 --- a/templates/foli/index.html +++ b/templates/foli/index.html @@ -13,7 +13,7 @@ <div class="card foli-card box-shadow content-spaced"> <div class="card-body"> - <p style="margin: 0;"> + <p class="m-0"> Bussissa voit ostaa kertalipun tai ladata matkakortin käteisellä. Samalla lipulla tai matkakortilla on vaihtoaikaa 2 tuntia. </p> diff --git a/templates/index.html b/templates/index.html index fbbf25f..e6f7b89 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,7 +8,7 @@ {% block scripts %} {{- super() }} - <script> + <script nonce="{{ g.csp_nonce }}"> $(function () { $('.menu-card').clickableBox(); }); diff --git a/templates/info.html b/templates/info.html index 6a65a3f..7c8f4b7 100644 --- a/templates/info.html +++ b/templates/info.html @@ -6,7 +6,11 @@ {% set content_lang = 'fi' %} {% block head %} + <meta name="twitter:widgets:csp" content="on"> + <meta name="twitter:widgets:autoload" content="off"> + {{- super() }} + <style> .h-auto { height: auto; } .info-card > #visitTurku { overflow-y: scroll; } @@ -17,7 +21,7 @@ but kiosk mode need not be responsive, since the screen resolution is fixed */ .iframe-container { width: 100%; - height: 100px; + height: 120px; position: relative; } .iframe-area, .iframe-cover { @@ -55,7 +59,7 @@ <div class="iframe-area"> <div id="forecast7"> <a class="weatherwidget-io" href="https://forecast7.com/en/60d4522d27/turku/" data-label_1="TURKU" data-label_2="Sää" data-theme="original">TURKU Sää</a> - <script> + <script nonce="{{ g.csp_nonce }}"> !function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0]; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = 'https://weatherwidget.io/js/widget.min.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'weatherwidget-io-js'); </script> </div> @@ -96,9 +100,9 @@ {{- super() }} <!-- TODO: once the cdnjs project hosts this, migrate to that --> - <script src="{{ util.static_url('js/jquery-rss/3.3.0/jquery.rss.min.js') }}"></script> + <script src="{{ util.static_url('js/jquery-rss/3.3.0/jquery.rss.min.js') }}" nonce="{{ g.csp_nonce }}"></script> - <script> + <script nonce="{{ g.csp_nonce }}"> $(document).ready(function () { // Twitter initialization code window.twttr = (function (d, s, id) { @@ -139,7 +143,11 @@ $('#twitter').disableExternalLinks(); // in case the widget fails to load disable the link in the container as a placeholder $('small.source-link').disableExternalLinks(); + // Note: disableExternalLinks() is not actually enough here, youtube videos and other embedded media are beyond its reach + // HTML5 sandboxing on the iframe may help, but it is more likely just to break the widget... twttr.ready(function (twttr) { + twttr.widgets.load(document.getElementById("twitter")); + twttr.events.bind('loaded', function (e) { e.widgets.forEach(function (w) { // the twitter widget is loaded in an iframe, contents() is needed because of that diff --git a/templates/internal/base.html b/templates/internal/base.html index fdebc8c..f49c0fd 100644 --- a/templates/internal/base.html +++ b/templates/internal/base.html @@ -139,7 +139,7 @@ </div> </div> - <small class="text-muted">{{ _("This application aggregates information from several sources, using open data or third party services, we take no responsibility on the accuracy of any such information authored by those third parties.") }}<small> + <small class="text-muted">{{ _("This application aggregates information from several sources, using open data or third party services, we take no responsibility on the accuracy of any such information authored by those third parties.") }}</small> </div> <div class="modal-footer"> <button type="button" class="btn btn-primary" data-dismiss="modal">{{ _("Close") }}</button> @@ -179,21 +179,21 @@ <!-- Optional JavaScript --> <!-- jQuery first, then Popper.js, then Bootstrap JS --> <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.qrcode/1.0/jquery.qrcode.min.js" integrity="sha256-9MzwK2kJKBmsJFdccXoIDDtsbWFh8bjYK/C7UjB1Ay0=" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.20.1/moment-with-locales.min.js" integrity="sha256-XWrGUqSiENmD8bL+BVeLl7iCfhs+pkPyIqrZQcS2Te8=" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> {% if stop_info.stop_timezone %} <script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.14/moment-timezone-with-data.min.js" integrity="sha256-FJZOELgwnfQRdG8KZUSWCYgucECDf4w5kfQdQSGbVpI=" - crossorigin="anonymous"></script> + crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script> {% endif %} - <script> + <script nonce="{{ g.csp_nonce }}"> // quick and dirty jquery "plugin" for making block level elements clickable by using a link they contain // TODO: this needs some serious refinement once we know how we implement changing of the views so that the whole page doesn't need to reload // FIXME: does not chain to previous click handler if it is already set! @@ -214,6 +214,8 @@ var handler = function (e) { e.preventDefault(); + e.stopPropagation(); + $link = $(this); $modal = $('#externalLinkModal'); @@ -227,10 +229,10 @@ }; // the two slashes are for the benefit of urls that contain refering URI in the query string - this.on('click', 'a[href^="http"]:not([href*="//{{ request.host }}"])', handler); + this.on('click', 'a[href^="http"]:not([href*="https://{{ request.host }}"])', handler); // disable links for foreign protocols, just in case - this.on('click', 'a[href^="mailto:"]', function(e) { e.preventDefault(); }); - this.on('click', 'a[href*="://"]:not([href^="http"])', function(e) { e.preventDefault(); }); + this.on('click', 'a[href^="mailto:"]', function(e) { e.preventDefault(); e.stopPropagation(); }); + this.on('click', 'a[href*="://"]:not([href^="http"])', function(e) { e.preventDefault(); e.stopPropagation(); }); // avoid duplicate calls (see info page, twitter widget) this.data('links-disabled', true); @@ -258,16 +260,18 @@ // clock implementation follows $clock = $('#headerClock'); - var clockFormat = 'H:mm'; var timeOffset = moment(new Date($clock.data('server-time'))).diff(moment()); var currentTime; var timerHandler = function() { currentTime = moment().add(timeOffset); - $clock.html(currentTime{% if stop_info.stop_timezone %}.tz('{{ stop_info.stop_timezone }}'){% endif %}.format(clockFormat)); + newTime = currentTime{% if stop_info.stop_timezone %}.tz('{{ stop_info.stop_timezone }}'){% endif %}.format('H:mm'); + + if (newTime !== $clock.html()) + $clock.html(newTime); - // run on next second wrap - setTimeout(timerHandler, (1000 - (new Date().getTime() % 1000))); + // run on next 5 second wrap to reduce unnecessary cycles + setTimeout(timerHandler, (5000 - (new Date().getTime() % 5000))); }; // start the clock diff --git a/templates/map.html b/templates/map.html index e4084d9..c7c2e93 100644 --- a/templates/map.html +++ b/templates/map.html @@ -6,6 +6,7 @@ {% block head %} {{- super() }} + <style> #map-canvas, #map-canvas > div { border-radius: 0.25rem; } </style> @@ -68,13 +69,14 @@ {% block scripts %} {{- super() }} - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-easing/1.4.1/jquery.easing.min.js"></script> - <script src="https://maps.googleapis.com/maps/api/js?key={{ gmaps_key }}&region={{ gmaps_region }}&language={{ g.lang_code }}"></script> + + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-easing/1.4.1/jquery.easing.min.js" nonce="{{ g.csp_nonce }}"></script> + <script src="https://maps.googleapis.com/maps/api/js?key={{ gmaps_key }}&region={{ gmaps_region }}&language={{ g.lang_code }}" nonce="{{ g.csp_nonce }}"></script> <!-- this is used to animate the movement of the buses on the map --> - <script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/vendor/markerAnimate.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/SlidingMarker.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/vendor/markerAnimate.js" nonce="{{ g.csp_nonce }}"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/SlidingMarker.min.js" nonce="{{ g.csp_nonce }}"></script> - <script> + <script nonce="{{ g.csp_nonce }}"> var gmarkers, gmap = false; // focusedMarker is the title of the focused marker -- GitLab