From 56dc2d277bf8f123555e62be929dae5bb63a2b54 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 25 May 2020 18:12:54 +0200
Subject: [PATCH] misc: limit cancel URL to relatable URLs (#43276)

---
 tests/test_form_pages.py | 41 ++++++++++++++++++++++++++++++++++++++++
 wcs/forms/root.py        |  2 ++
 wcs/qommon/publisher.py  | 18 ++++++++++++++++++
 3 files changed, 61 insertions(+)

diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 08076ecc9..397cb64c4 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -428,6 +428,47 @@ def test_form_access_auth_context(pub):
     assert resp.form
 
 
+def test_form_cancelurl(pub):
+    formdef = create_formdef()
+    formdef.data_class().wipe()
+
+    # path
+    resp = get_app(pub).get('/test/?cancelurl=/plop/')
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.net/plop/'
+
+    # full URL
+    resp = get_app(pub).get('/test/?cancelurl=http://example.net/plop/')
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.net/plop/'
+
+    # remote site
+    get_app(pub).get('/test/?cancelurl=http://example.org/plop/', status=400)
+
+    pub.site_options.add_section('api-secrets')
+    pub.site_options.set('api-secrets', 'example.org', 'xyz')
+    pub.site_options.write(open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w'))
+    resp = get_app(pub).get('/test/?cancelurl=http://example.org/plop/')
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.org/plop/'
+
+    pub.site_options.remove_section('api-secrets')
+    if not pub.site_options.has_section('options'):
+        pub.site_options.add_section('options')
+    pub.site_options.write(open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w'))
+    get_app(pub).get('/test/?cancelurl=http://example.org/plop/', status=400)
+
+    pub.site_options.set('options', 'relatable-hosts', 'example.com')
+    pub.site_options.write(open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w'))
+    get_app(pub).get('/test/?cancelurl=http://example.org/plop/', status=400)
+
+    pub.site_options.set('options', 'relatable-hosts', 'example.com, example.org')
+    pub.site_options.write(open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w'))
+    resp = get_app(pub).get('/test/?cancelurl=http://example.org/plop/')
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.org/plop/'
+
+
 def test_form_submit(pub):
     formdef = create_formdef()
     formdef.data_class().wipe()
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index b7295b1df..cf63ed50c 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -328,6 +328,8 @@ class FormPage(Directory, FormTemplateMixin):
 
         if page == self.pages[0] and 'cancelurl' in get_request().form:
             cancelurl = get_request().form['cancelurl']
+            if not get_publisher().is_relatable_url(cancelurl):
+                raise errors.RequestError('invalid cancel URL')
             form_data['__cancelurl'] = cancelurl
             session.add_magictoken(magictoken, form_data)
 
diff --git a/wcs/qommon/publisher.py b/wcs/qommon/publisher.py
index b2bfd97a8..58c637437 100644
--- a/wcs/qommon/publisher.py
+++ b/wcs/qommon/publisher.py
@@ -386,6 +386,7 @@ class QommonPublisher(Publisher, object):
         defaults = {
             'options': {
                 'unused-files-behaviour': 'remove',
+                'relatable-hosts': '',
             },
         }
         if self.site_options is None:
@@ -956,6 +957,23 @@ class QommonPublisher(Publisher, object):
         d['manager_homepage_title'] = d.get('portal_agent_title')
         return d
 
+    def is_relatable_url(self, url):
+        parsed_url = urllib.urlparse(url)
+        if not parsed_url.netloc:
+            return True
+        if parsed_url.netloc == urllib.urlparse(self.get_frontoffice_url()).netloc:
+            return True
+        if parsed_url.netloc == urllib.urlparse(self.get_backoffice_url()).netloc:
+            return True
+        if parsed_url.netloc in [x.strip() for x in self.get_site_option('relatable-hosts').split(',')]:
+            return True
+        try:
+            if parsed_url.netloc in self.site_options.options('api-secrets'):
+                return True
+        except ConfigParser.NoSectionError:
+            pass
+        return False
+
     extra_sources = []
     @classmethod
     def register_extra_source(cls, source):
-- 
2.27.0.rc0

