[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