[Git][debian-gis-team/mapproxy][master] 4 commits: New upstream version 4.0.2+dfsg

Bas Couwenberg (@sebastic) gitlab at salsa.debian.org
Thu Apr 10 15:03:33 BST 2025



Bas Couwenberg pushed to branch master at Debian GIS Project / mapproxy


Commits:
52222bfb by Bas Couwenberg at 2025-04-10T15:57:18+02:00
New upstream version 4.0.2+dfsg
- - - - -
dfd47f2d by Bas Couwenberg at 2025-04-10T15:57:20+02:00
Update upstream source from tag 'upstream/4.0.2+dfsg'

Update to upstream version '4.0.2+dfsg'
with Debian dir 2f910e961d1d3048a58b724fb54eef2b55864920
- - - - -
561a8a77 by Bas Couwenberg at 2025-04-10T15:58:52+02:00
New upstream release.

- - - - -
c19913a1 by Bas Couwenberg at 2025-04-10T15:59:31+02:00
Set distribution to unstable.

- - - - -


15 changed files:

- CHANGES.txt
- DEVELOPMENT.md
- RELEASE.txt
- debian/changelog
- doc/deployment.rst
- mapproxy/config/loader.py
- mapproxy/multiapp.py
- mapproxy/service/demo.py
- mapproxy/service/kml.py
- mapproxy/service/templates/demo/capabilities_demo.html
- mapproxy/test/system/fixture/util_grids.yaml
- mapproxy/test/system/test_demo.py
- + mapproxy/util/escape.py
- mapproxy/wsgiapp.py
- setup.py


Changes:

=====================================
CHANGES.txt
=====================================
@@ -1,3 +1,12 @@
+4.0.2 2025-04-10
+~~~~~~~~~~~~~~~~
+
+Fixes:
+
+- Security fix to prevent XSS injections in demo pages
+- Security fix to prevent reading file urls
+
+
 4.0.1 2025-03-25
 ~~~~~~~~~~~~~~~~
 


=====================================
DEVELOPMENT.md
=====================================
@@ -23,6 +23,12 @@ PEP8: <https://www.python.org/dev/peps/pep-0008/>
 Debugging
 ---------
 
+* With Intellij
+  * Create run configuration
+  * Select script `mapproxy-util` from the `bin` folder in your venv folder.
+  * Add script parameters `serve-develop apps/base/mapproxy.yaml`.
+  * Start configuration in debug mode, no need to start mapproxy in debug mode.
+
 * With PyCharm:
   * Attach to dev server with <https://www.jetbrains.com/help/pycharm/attaching-to-local-process.html>
 


=====================================
RELEASE.txt
=====================================
@@ -5,11 +5,14 @@ Preparation
 -----------
 
 - Stash changes, checkout `master` and pull all changes (`git stash && git checkout master && git pull`)
+- Create a new branch for a pull request (`git checkout -b 2.0.2`, the name is irrelevant here)
 - Update CHANGES.txt with all important changes. Verify version and date in header line.
   IMPORTANT: Be careful to have the proper amount of blank lines.
   You can use the git compare function for that, e.g.: https://github.com/mapproxy/mapproxy/compare/2.0.2...master
 - Update version in `setup.py`.
 - Commit and push updates (`git add -A && git commit -m 'dev: prepare 2.0.2 release' && git push`)
+- Create a pull request on github
+- After the pull request was merged, check out master and pull (`git checkout master && git pull`)
 - Create tag and push tag (`git tag 2.0.2 && git push --tags`)
 
 


=====================================
debian/changelog
=====================================
@@ -1,3 +1,9 @@
+mapproxy (4.0.2+dfsg-1) unstable; urgency=high
+
+  * New upstream release.
+
+ -- Bas Couwenberg <sebastic at debian.org>  Thu, 10 Apr 2025 15:59:21 +0200
+
 mapproxy (4.0.1+dfsg-1) unstable; urgency=medium
 
   * New upstream release.


=====================================
doc/deployment.rst
=====================================
@@ -49,6 +49,8 @@ Behind an HTTP server or proxy
 
 Both approaches require a configuration that maps your MapProxy configuration with the MapProxy application. You can write a small script file for that.
 
+.. note:: In production mode it is recommended to disable the demo service.
+
 .. _server_script:
 
 Server script


=====================================
mapproxy/config/loader.py
=====================================
@@ -2159,7 +2159,6 @@ class ServiceConfiguration(ConfigurationBase):
             creator = getattr(self, service_name + '_service', None)
             if not creator:
                 # If not a known service, try to use the plugin mechanism
-                global plugin_services
                 creator = plugin_services.get(service_name, None)
                 if not creator:
                     raise ValueError('unknown service: %s' % service_name)
@@ -2409,6 +2408,10 @@ def load_configuration(mapproxy_conf, seed=False, ignore_warnings=True, renderd=
     for error in errors:
         log.warning(error)
 
+    services = conf_dict.get('services')
+    if services is not None and 'demo' in services:
+        log.warning('Application has demo page enabled. It is recommended to disable this in production.')
+
     return ProxyConfiguration(conf_dict, conf_base_dir=conf_base_dir, seed=seed,
                               renderd=renderd)
 


=====================================
mapproxy/multiapp.py
=====================================
@@ -20,6 +20,7 @@ from mapproxy.request import Request
 from mapproxy.response import Response
 from mapproxy.util.collections import LRU
 from mapproxy.wsgiapp import make_wsgi_app as make_mapproxy_wsgi_app
+from mapproxy.util.escape import escape_html
 
 from threading import Lock
 
@@ -95,7 +96,7 @@ class MultiMapProxy(object):
         import mapproxy.version
         html = "<html><body><h1>Welcome to MapProxy %s</h1>" % mapproxy.version.version
 
-        url = req.script_url
+        url = escape_html(req.script_url)
         if self.list_apps:
             html += "<h2>available instances:</h2><ul>"
             html += '\n'.join('<li><a href="%(url)s/%(name)s/">%(name)s</a></li>' % {'url': url, 'name': app}


=====================================
mapproxy/service/demo.py
=====================================
@@ -18,6 +18,9 @@ Demo service handler
 """
 from __future__ import division
 
+import logging
+import re
+
 try:
     import importlib_resources
 except ImportError:
@@ -30,10 +33,12 @@ from mapproxy.config.config import base_config
 from mapproxy.util.ext.odict import odict
 from mapproxy.exception import RequestError
 from mapproxy.service.base import Server
+from mapproxy.request.base import Request
 from mapproxy.response import Response
 from mapproxy.srs import SRS, get_epsg_num
 from mapproxy.layer import SRSConditional, CacheMapLayer, ResolutionConditional
 from mapproxy.source.wms import WMSSource
+from mapproxy.util.escape import escape_html
 
 from urllib import request as urllib2
 
@@ -44,6 +49,8 @@ get_template = template_loader(__package__, 'templates', namespace=env)
 # Used by plugins
 extra_demo_server_handlers = set()
 
+logger = logging.getLogger("demo")
+
 
 def register_extra_demo_server_handler(handler):
     """ Method used by plugins to register a new handler for the demo service.
@@ -106,7 +113,29 @@ class DemoServer(Server):
         self.restful_template = restful_template
         self.background = background
 
-    def handle(self, req):
+    @staticmethod
+    def get_capabilities_url(service_path: str, req: Request) -> str:
+        if 'type' in req.args and req.args['type'] == 'external':
+            url = escape_html(req.script_url) + service_path
+        else:
+            url = req.server_script_url + service_path
+        return url
+
+    @staticmethod
+    def valid_url(url: str) -> bool:
+        pattern = re.compile("^https?://")
+        if pattern.match(url):
+            return True
+        else:
+            logging.warn(f"A request was blocked that was trying to access a non-http resource: {url}")
+            return False
+
+    @staticmethod
+    def read_capabilities(url: str) -> str:
+        resp = urllib2.urlopen(url)
+        return escape_html(resp.read().decode())
+
+    def handle(self, req: Request):
         if req.path.startswith('/demo/static/'):
             if '..' in req.path:
                 return Response('file not found', content_type='text/plain', status=404)
@@ -138,56 +167,47 @@ class DemoServer(Server):
         elif 'wmts_layer' in req.args:
             demo = self._render_wmts_template('demo/wmts_demo.html', req)
         elif 'wms_capabilities' in req.args:
-            internal_url = '%s/service?REQUEST=GetCapabilities&SERVICE=WMS' % (req.server_script_url)
-            if 'type' in req.args and req.args['type'] == 'external':
-                url = internal_url.replace(req.server_script_url, req.script_url)
-            else:
-                url = internal_url
-            capabilities = urllib2.urlopen(url)
+            url = self.get_capabilities_url('/service?REQUEST=GetCapabilities&SERVICE=WMS', req)
+            if not self.valid_url(url):
+                return Response('bad request', content_type='text/plain', status=400)
+            capabilities = self.read_capabilities(url)
             demo = self._render_capabilities_template('demo/capabilities_demo.html', capabilities, 'WMS', url)
         elif 'wmsc_capabilities' in req.args:
-            internal_url = '%s/service?REQUEST=GetCapabilities&SERVICE=WMS&tiled=true' % (req.server_script_url)
-            if 'type' in req.args and req.args['type'] == 'external':
-                url = internal_url.replace(req.server_script_url, req.script_url)
-            else:
-                url = internal_url
-            capabilities = urllib2.urlopen(url)
+            url = self.get_capabilities_url('/service?REQUEST=GetCapabilities&SERVICE=WMS&tiled=true', req)
+            if not self.valid_url(url):
+                return Response('bad request', content_type='text/plain', status=400)
+            capabilities = self.read_capabilities(url)
             demo = self._render_capabilities_template('demo/capabilities_demo.html', capabilities, 'WMS-C', url)
         elif 'wmts_capabilities_kvp' in req.args:
-            internal_url = '%s/service?REQUEST=GetCapabilities&SERVICE=WMTS' % (req.server_script_url)
-            if 'type' in req.args and req.args['type'] == 'external':
-                url = internal_url.replace(req.server_script_url, req.script_url)
-            else:
-                url = internal_url
-            capabilities = urllib2.urlopen(url)
+            url = self.get_capabilities_url('/service?REQUEST=GetCapabilities&SERVICE=WMTS', req)
+            if not self.valid_url(url):
+                return Response('bad request', content_type='text/plain', status=400)
+            capabilities = self.read_capabilities(url)
             demo = self._render_capabilities_template('demo/capabilities_demo.html', capabilities, 'WMTS', url)
         elif 'wmts_capabilities' in req.args:
-            internal_url = '%s/wmts/1.0.0/WMTSCapabilities.xml' % (req.server_script_url)
-            if 'type' in req.args and req.args['type'] == 'external':
-                url = internal_url.replace(req.server_script_url, req.script_url)
-            else:
-                url = internal_url
-            capabilities = urllib2.urlopen(url)
+            url = self.get_capabilities_url('/wmts/1.0.0/WMTSCapabilities.xml', req)
+            if not self.valid_url(url):
+                return Response('bad request', content_type='text/plain', status=400)
+            capabilities = self.read_capabilities(url)
             demo = self._render_capabilities_template('demo/capabilities_demo.html', capabilities, 'WMTS', url)
         elif 'tms_capabilities' in req.args:
             if 'layer' in req.args and 'srs' in req.args:
-                # prevent dir traversal (seems it's not possible with urllib2, but better safe then sorry)
-                layer = req.args['layer'].replace('..', '')
-                srs = req.args['srs'].replace('..', '')
-                internal_url = '%s/tms/1.0.0/%s/%s' % (req.server_script_url, layer, srs)
-            else:
-                internal_url = '%s/tms/1.0.0/' % (req.server_script_url)
-            if 'type' in req.args and req.args['type'] == 'external':
-                url = internal_url.replace(req.server_script_url, req.script_url)
+                # prevent dir traversal (seems it's not possible with urllib2, but better safe than sorry)
+                layer = escape_html(req.args['layer'].replace('..', ''))
+                srs = escape_html(req.args['srs'].replace('..', ''))
+                service_path = f'/tms/1.0.0/{layer}/{srs}'
             else:
-                url = internal_url
-            capabilities = urllib2.urlopen(url)
+                service_path = '/tms/1.0.0/'
+            url = self.get_capabilities_url(service_path, req)
+            if not self.valid_url(url):
+                return Response('bad request', content_type='text/plain', status=400)
+            capabilities = self.read_capabilities(url)
             demo = self._render_capabilities_template('demo/capabilities_demo.html', capabilities, 'TMS', url)
         elif req.path == '/demo/':
             demo = self._render_template(req, 'demo/demo.html')
         else:
             resp = Response('', status=301)
-            resp.headers['Location'] = req.script_url.rstrip('/') + '/demo/'
+            resp.headers['Location'] = escape_html(req.script_url).rstrip('/') + '/demo/'
             return resp
         return Response(demo, content_type='text/html')
 
@@ -258,7 +278,7 @@ class DemoServer(Server):
     def _render_wms_template(self, template, req):
         template = get_template(template, default_inherit="demo/static.html")
         layer = self.layers[req.args['wms_layer']]
-        srs = escape(req.args['srs'])
+        srs = escape_html(req.args['srs'])
         bbox = layer.extent.bbox_for(SRS(srs))
         width = bbox[2] - bbox[0]
         height = bbox[3] - bbox[1]
@@ -268,7 +288,7 @@ class DemoServer(Server):
             background_url = self.background["url"]
         return template.substitute(layer=layer,
                                    image_formats=self.image_formats,
-                                   format=escape(req.args['format']),
+                                   format=escape_html(req.args['format']),
                                    srs=srs,
                                    layer_srs=self.layer_srs,
                                    bbox=bbox,
@@ -301,8 +321,8 @@ class DemoServer(Server):
         if self.background:
             background_url = self.background["url"]
         return template.substitute(layer=tile_layer,
-                                   srs=escape(req.args['srs']),
-                                   format=escape(req.args['format']),
+                                   srs=escape_html(req.args['srs']),
+                                   format=escape_html(req.args['format']),
                                    resolutions=res,
                                    units=units,
                                    add_res_to_options=add_res_to_options,
@@ -332,8 +352,8 @@ class DemoServer(Server):
             background_url = self.background["url"]
         return template.substitute(layer=wmts_layer,
                                    matrix_set=wmts_layer.grid.name,
-                                   format=escape(req.args['format']),
-                                   srs=escape(req.args['srs']),
+                                   format=escape_html(req.args['format']),
+                                   srs=escape_html(req.args['srs']),
                                    resolutions=wmts_layer.grid.resolutions,
                                    units=units,
                                    all_tile_layers=self.tile_layers,
@@ -356,15 +376,3 @@ class DemoServer(Server):
                 return True
             return False
         return True
-
-
-def escape(data):
-    """
-    Escape user-provided input data for safe inclusion in HTML _and_ JS to prevent XSS.
-    """
-    data = data.replace('&', '&')
-    data = data.replace('>', '>')
-    data = data.replace('<', '<')
-    data = data.replace("'", '')
-    data = data.replace('"', '')
-    return data


=====================================
mapproxy/service/kml.py
=====================================
@@ -21,6 +21,7 @@ from mapproxy.service.base import Server
 from mapproxy.request.tile import TileRequest
 from mapproxy.srs import SRS
 from mapproxy.util.coverage import load_limited_to
+from mapproxy.util.escape import escape_html
 
 
 class KMLRequest(TileRequest):
@@ -182,7 +183,7 @@ class KMLServer(Server):
 
         subtiles = self._get_subtiles(map_request, layer)
         tile_size = layer.grid.tile_size[0]
-        url = map_request.http.script_url.rstrip('/')
+        url = escape_html(map_request.http.script_url.rstrip('/'))
         result = KMLRenderer().render(
             tile=tile, subtiles=subtiles, layer=layer, url=url, name=map_request.layer, format=layer.format,
             name_path=layer.md['name_path'], initial_level=initial_level, tile_size=tile_size)


=====================================
mapproxy/service/templates/demo/capabilities_demo.html
=====================================
@@ -11,8 +11,6 @@ jscript_functions = None
             <h2>{{service}} GetCapabilities</h2>
             <a href="{{url}}">{{url}}</a>
             <pre>
-{{for line in capabilities}}
-{{escape(wrapper.fill(line.decode('utf8')))}}
-{{endfor}}
+{{capabilities}}
             </pre>
 


=====================================
mapproxy/test/system/fixture/util_grids.yaml
=====================================
@@ -1,5 +1,6 @@
 services:
-    demo:
+    wms:
+
 layers:
     - name: grid_layer
       title: Grid Layer


=====================================
mapproxy/test/system/test_demo.py
=====================================
@@ -16,7 +16,9 @@
 import re
 import pytest
 
+from mapproxy.test.http import mock_httpd
 from mapproxy.test.system import SysTest
+from webtest import AppError
 
 
 @pytest.fixture(scope="module")
@@ -54,3 +56,68 @@ class TestDemo(SysTest):
         assert layersWMS == sorted(layersWMS)
         assert layersWMTS == sorted(layersWMTS)
         assert layersTBS == sorted(layersTBS)
+
+    def test_external(self, app):
+        expected_req = (
+            {
+                "path": r"/path/service?REQUEST=GetCapabilities&SERVICE=WMS"
+            },
+            {"body": b"test-string", "headers": {"content-type": "text/xml"}}
+        )
+        with mock_httpd(
+                ("localhost", 42423), [expected_req]
+        ):
+            resp = app.get('/demo/?wms_capabilities&type=external', extra_environ={
+                'HTTP_X_FORWARDED_HOST': 'localhost:42423/path'
+            })
+            content = resp.text
+            assert 'test-string' in content
+            assert 'http://localhost:42423/path/service?REQUEST=GetCapabilities&SERVICE=WMS' in content
+
+    def test_external_xss_injection(self, app):
+        expected_req = (
+            {
+                "path": r"/path/><script>alert(XSS)</script>/service?REQUEST=GetCapabilities&SERVICE=WMS"
+            },
+            {"body": b"test-string", "headers": {"content-type": "text/xml"}}
+        )
+
+        with mock_httpd(
+                ("localhost", 42423), [expected_req]
+        ):
+            resp = app.get('/demo/?wms_capabilities&type=external', extra_environ={
+                'HTTP_X_FORWARDED_HOST': 'localhost:42423/path/"><script>alert(\'XSS\')</script>'
+            })
+        content = resp.text
+        assert 'test-string' in content
+        assert '"><script>alert(\'XSS\')' not in content
+        assert '><script>alert(XSS)</script>' in content
+
+    def test_external_file_protocol(self, app):
+        try:
+            app.get('/demo/?wms_capabilities&type=external', extra_environ={
+                'HTTP_X_FORWARDED_PROTO': 'file'
+            })
+        except AppError as e:
+            assert '400 Bad Request' in e.args[0]
+
+    def test_tms_layer_xss(self, app):
+        expected_req = (
+            {
+                "path": r"/tms/1.0.0/osm><script>alert(XSS)</script>/1.0.0"
+            },
+            {"body": b"test-string", "headers": {"content-type": "text/xml"}}
+        )
+
+        with mock_httpd(
+                ("localhost", 42423), [expected_req]
+        ):
+            resp = app.get(
+                '/demo/?tms_capabilities&layer=osm"><script>alert(\'XSS\')</script>&type=external&srs=1.0.0',
+                extra_environ={
+                    'HTTP_X_FORWARDED_HOST': 'localhost:42423'
+                }
+            )
+        content = resp.text
+        assert '"><script>alert(\'XSS\')' not in content
+        assert '><script>alert(XSS)</script>' in content


=====================================
mapproxy/util/escape.py
=====================================
@@ -0,0 +1,10 @@
+def escape_html(data):
+    """
+    Escape user-provided input data for safe inclusion in HTML _and_ JS to prevent XSS.
+    """
+    data = data.replace('&', '&')
+    data = data.replace('>', '>')
+    data = data.replace('<', '<')
+    data = data.replace("'", '')
+    data = data.replace('"', '')
+    return data


=====================================
mapproxy/wsgiapp.py
=====================================
@@ -34,6 +34,7 @@ from mapproxy.request import Request
 from mapproxy.response import Response
 from mapproxy.config import local_base_config
 from mapproxy.config.loader import load_configuration, ConfigurationError
+from mapproxy.util.escape import escape_html
 
 log = logging.getLogger('mapproxy.config')
 log_wsgiapp = logging.getLogger('mapproxy.wsgiapp')
@@ -164,7 +165,7 @@ class MapProxyApp(object):
                             resp = Response('internal error', status=500)
             if resp is None:
                 if req.path in ('', '/'):
-                    resp = self.welcome_response(req.script_url)
+                    resp = self.welcome_response(escape_html(req.script_url))
                 else:
                     resp = Response('not found', mimetype='text/plain', status=404)
             return resp(environ, start_response)


=====================================
setup.py
=====================================
@@ -62,7 +62,7 @@ def long_description(changelog_releases=10):
 
 setup(
     name='MapProxy',
-    version="4.0.1",
+    version="4.0.2",
     description='An accelerating proxy for tile and web map services',
     long_description=long_description(7),
     long_description_content_type='text/x-rst',



View it on GitLab: https://salsa.debian.org/debian-gis-team/mapproxy/-/compare/32b361f06d6d3306b369df136f3b72dcce67eca8...c19913a16f4ad34c3c3e9572b039dedd3433af43

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/mapproxy/-/compare/32b361f06d6d3306b369df136f3b72dcce67eca8...c19913a16f4ad34c3c3e9572b039dedd3433af43
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20250410/4510f2e3/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list