Repository: GoogleCloudPlatform/flask-talisman

Issue:
Support Feature Policy
The Feature-Policy is a relatively new header which is supported in Chrome which would be nice to have supported as well.

https://scotthelme.co.uk/a-new-security-header-feature-policy/

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/18255425/badge)](https://coveralls.io/builds/18255425)

Coverage remained the same at 100.0% when pulling **e5ca15b7f7372c174495de7b83b73a6f037fc1e1 on jelly:feature_policy** into **eb5bd5a241ef59e508a2e6f8076a12a49099855d on GoogleCloudPlatform:master**.

theacodes: This looks great, can you get lint passes on travis?

Patch:
diff --git a/README.rst b/README.rst
index 0a7258d..9198b94 100644
--- a/README.rst
+++ b/README.rst
@@ -70,6 +70,7 @@ There is also a full `Example App <https://github.com/GoogleCloudPlatform/flask-
 Options
 -------
 
+-  ``feature_policy``, default ``{}``, see the `Feature Policy` section.
 -  ``force_https``, default ``True``, forces all non-debug connects to
    ``https``.
 -  ``force_https_permanent``, default ``False``, uses ``301`` instead of
@@ -277,6 +278,25 @@ The nonce needs to be added to the script tag in the template:
 Note that the CSP directive (`script-src` in the example) to which the `nonce-...`
 source should be added needs to be defined explicitly.
 
+Feature Policy
+--------------
+
+The default feature policy is empty, as this is the default expected behaviour.
+Note that the Feature Policy is still a `draft https://wicg.github.io/feature-policy/`
+and supported in Chrome and Safari.
+
+Geolocation Example
+~~~~~~~~~~~~~~~~~~~
+
+Disable access to Geolocation interface.
+
+.. code:: python
+
+    feature_policy = {
+        'geolocation': '\'none\''
+    }
+    talisman = Talisman(app, feature_policy=feature_policy)
+
 Disclaimer
 ----------
 
diff --git a/example_app/main.py b/example_app/main.py
index 8edfd9c..fcc22df 100644
--- a/example_app/main.py
+++ b/example_app/main.py
@@ -37,6 +37,9 @@ talisman = Talisman(
         ],
     },
     content_security_policy_nonce_in=['script-src'],
+    feature_policy={
+        'geolocation': '\'none\'',
+    }
 )
 
 
diff --git a/example_app/templates/index.html b/example_app/templates/index.html
index 06772bc..42e5b1c 100644
--- a/example_app/templates/index.html
+++ b/example_app/templates/index.html
@@ -41,6 +41,9 @@
 <script nonce="{{ csp_nonce() }}">
   // This one isn't
   console.log("Yay, nonce allowed to run this.")
+  navigator.geolocation.getCurrentPosition(function(position) {
+    console.log('Oh no, geolocation access should be denied');
+  });
 </script>
 
 </body>
diff --git a/flask_talisman/__init__.py b/flask_talisman/__init__.py
index 5329fac..acac8df 100644
--- a/flask_talisman/__init__.py
+++ b/flask_talisman/__init__.py
@@ -13,12 +13,13 @@
 # limitations under the License.
 
 from .talisman import (
-    ALLOW_FROM, DEFAULT_CSP_POLICY, DENY, GOOGLE_CSP_POLICY, SAMEORIGIN,
-    Talisman)
+    ALLOW_FROM, DEFAULT_CSP_POLICY, DEFAULT_FEATURE_POLICY, DENY,
+    GOOGLE_CSP_POLICY, SAMEORIGIN, Talisman)
 
 __all__ = (
     'ALLOW_FROM',
     'DEFAULT_CSP_POLICY',
+    'DEFAULT_FEATURE_POLICY',
     'DENY',
     'GOOGLE_CSP_POLICY',
     'SAMEORIGIN',
diff --git a/flask_talisman/talisman.py b/flask_talisman/talisman.py
index ae39b76..8f892e1 100644
--- a/flask_talisman/talisman.py
+++ b/flask_talisman/talisman.py
@@ -44,6 +44,9 @@ GOOGLE_CSP_POLICY = {
     'default-src': '\'self\' *.gstatic.com',
 }
 
+DEFAULT_FEATURE_POLICY = {
+}
+
 NONCE_LENGTH = 16
 
 
@@ -59,6 +62,7 @@ class Talisman(object):
     def init_app(
             self,
             app,
+            feature_policy=DEFAULT_FEATURE_POLICY,
             force_https=True,
             force_https_permanent=False,
             force_file_save=False,
@@ -80,6 +84,8 @@ class Talisman(object):
 
         Args:
             app: A Flask application.
+            feature_policy: A string or dictionary describing the
+                feature policy for the response.
             force_https: Redirects non-http requests to https, disabled in
                 debug mode.
             force_https_permanent: Uses 301 instead of 302 redirects.
@@ -116,6 +122,7 @@ class Talisman(object):
         See README.rst for a detailed description of each option.
         """
 
+        self.feature_policy = feature_policy.copy()
         self.force_https = force_https
         self.force_https_permanent = force_https_permanent
 
@@ -174,6 +181,9 @@ class Talisman(object):
             'frame_options_allow_from', self.frame_options_allow_from)
         view_options.setdefault(
             'content_security_policy', self.content_security_policy)
+        view_options.setdefault(
+            'feature_policy', self.feature_policy
+        )
 
         return view_options
 
@@ -207,6 +217,7 @@ class Talisman(object):
     def _set_response_headers(self, response):
         """Applies all configured headers to the given response."""
         options = self._get_local_options()
+        self._set_feature_headers(response.headers, options)
         self._set_frame_options_headers(response.headers, options)
         self._set_content_security_policy_headers(response.headers, options)
         self._set_hsts_headers(response.headers)
@@ -224,25 +235,7 @@ class Talisman(object):
     def _get_nonce(self):
         return getattr(flask.request, 'csp_nonce', '')
 
-    def _set_frame_options_headers(self, headers, options):
-        headers['X-Frame-Options'] = options['frame_options']
-
-        if options['frame_options'] == ALLOW_FROM:
-            headers['X-Frame-Options'] += " {}".format(
-                options['frame_options_allow_from'])
-
-    def _set_content_security_policy_headers(self, headers, options):
-        headers['X-XSS-Protection'] = '1; mode=block'
-        headers['X-Content-Type-Options'] = 'nosniff'
-
-        if self.force_file_save:
-            headers['X-Download-Options'] = 'noopen'
-
-        if not options['content_security_policy']:
-            return
-
-        policy = options['content_security_policy']
-
+    def _parse_policy(self, policy):
         if isinstance(policy, string_types):
             # parse the string into a policy dict
             policy_string = policy
@@ -267,6 +260,37 @@ class Talisman(object):
 
         policy = '; '.join(policies)
 
+        return policy
+
+    def _set_feature_headers(self, headers, options):
+        if not options['feature_policy']:
+            return
+
+        policy = options['feature_policy']
+        policy = self._parse_policy(policy)
+
+        headers['Feature-Policy'] = policy
+
+    def _set_frame_options_headers(self, headers, options):
+        headers['X-Frame-Options'] = options['frame_options']
+
+        if options['frame_options'] == ALLOW_FROM:
+            headers['X-Frame-Options'] += " {}".format(
+                options['frame_options_allow_from'])
+
+    def _set_content_security_policy_headers(self, headers, options):
+        headers['X-XSS-Protection'] = '1; mode=block'
+        headers['X-Content-Type-Options'] = 'nosniff'
+
+        if self.force_file_save:
+            headers['X-Download-Options'] = 'noopen'
+
+        if not options['content_security_policy']:
+            return
+
+        policy = options['content_security_policy']
+        policy = self._parse_policy(policy)
+
         if self.content_security_policy_report_uri and \
                 'report-uri' not in policy:
             policy += '; report-uri ' + self.content_security_policy_report_uri


Test patch:
diff --git a/flask_talisman/talisman_test.py b/flask_talisman/talisman_test.py
index e76fafc..a9f5b1a 100644
--- a/flask_talisman/talisman_test.py
+++ b/flask_talisman/talisman_test.py
@@ -238,3 +238,14 @@ class TestTalismanExtension(unittest.TestCase):
         response = self.client.get('/bad_endpoint',
                                    headers={'X-Forwarded-Proto': 'https'})
         self.assertEqual(response.status_code, 404)
+
+    def testFeaturePolicy(self):
+        self.talisman.feature_policy['geolocation'] = '\'none\''
+        response = self.client.get('/', environ_overrides=HTTPS_ENVIRON)
+        feature_policy = response.headers['Feature-Policy']
+        self.assertTrue('geolocation \'none\'' in feature_policy)
+
+        self.talisman.feature_policy['fullscreen'] = '\'self\' example.com'
+        response = self.client.get('/', environ_overrides=HTTPS_ENVIRON)
+        feature_policy = response.headers['Feature-Policy']
+        self.assertTrue('fullscreen \'self\' example.com' in feature_policy)