XSS
Description
The commit refactors GIS geometry widgets to remove inline JavaScript from templates and move initialization to external JavaScript. Previously, the GIS widgets rendered inline JavaScript in templates that interpolated template context values (e.g., geom_type, id, name, map_srid) directly into a JavaScript object and instantiated MapWidget. If any of these context values could be influenced by user input and not properly escaped in the JS context, this could lead to cross-site scripting (XSS) via injected script code in the resulting page. The fix removes the inline script blocks, uses json_script to pass data safely to a separate JavaScript file, and relies on a client-side MapWidget that reads data from a safe data container. This reduces the attack surface for XSS by avoiding inline script construction with untrusted data and moving initialization into external JavaScript.
Proof of Concept
Proof-of-Concept (explanation only; do not deploy with unpatched templates):
Background before the fix (inline JS vulnerability): a Django template for the GIS OpenLayers widget rendered something like this inline script, directly embedding template context values into a JavaScript object and then instantiating the widget:
<script>
var options = {
base_layer: base_layer,
geom_name: '{{ geom_type }}',
id: '{{ id }}',
map_id: '{{ id }}_map',
map_srid: {{ map_srid }},
name: '{{ name }}'
};
var {{ module }} = new MapWidget(options);
</script>
If an attacker could influence the values assigned to name or geom_type (and if escaping was insufficient in the inline JS context), they could inject arbitrary JavaScript payloads that would execute in the victim’s browser when the page rendered.
Attack scenario (demonstrative):
- Setup: A Django form renders a geometry widget (OSM/OpenLayers widget) on a page. The attacker submits a value for the widget’s name field that contains a JavaScript payload designed to break out of the string literal in the inline script, e.g. name = "evil'); alert(1); //".
- Payload rendering (pre-fix): The template would render the inline script with name replaced by the attacker payload. The resulting JavaScript could become something like:
var options = {
base_layer: base_layer,
geom_name: 'Polygon',
id: 'id_123',
map_id: 'id_123_map',
map_srid: 3857,
name: 'evil'); alert(1); //' // Note: this is illustrative; actual escaping depends on Django rendering
};
var MapWidget_123 = new MapWidget(options);
- Result: The injected alert(1) (or other payload) executes in the victim’s browser, constituting an XSS vulnerability.
Fix (what the PoC demonstrates as mitigated): The commit removes the inline script and uses a JSON data container via json_script (safe encoding) to pass widget options to an external JavaScript module (MapWidget). The MapWidget.js reads the data from a safe source, and no inline string interpolation occurs within a script tag that could be tainted by user input. This prevents the class of XSS introduced by injecting into inline JS blocks.
Note: This PoC is conceptual for understanding the vulnerability prior to the fix. Actual exploitability depends on the exact escaping behavior in the Django template version in use and how data is provided to the inline script. The patched version eliminates this inline snippet and uses safe data passing, mitigating the issue.
Commit Details
Author: Claude Paroz
Date: 2024-08-18 13:29 UTC
Message:
Fixed #25706 -- Refactored geometry widgets to remove inline JavaScript.
Refactored GIS-related JavaScript initialization to eliminate inline
scripts from templates. Added support for specifying a base layer using
the new `base_layer_name` attribute on `BaseGeometryWidget`, allowing
custom map tile providers via user-defined JavaScript.
As a result, the `gis/openlayers-osm.html` template was removed.
Thanks Sarah Boyce for reviews.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Triage Assessment
Vulnerability Type: XSS
Confidence: HIGH
Reasoning:
The commit refactors GIS widgets to remove inline JavaScript from templates, reducing the attack surface for cross-site scripting (XSS). It externalizes map initialization and base layer handling, mitigating risks associated with inline scripts in templates.
Verification Assessment
Vulnerability Type: XSS
Confidence: HIGH
Affected Versions: 5.1.x (stable/5.1.x) and earlier
Code Diff
diff --git a/django/contrib/gis/forms/widgets.py b/django/contrib/gis/forms/widgets.py
index 55895ae9f362..c8f9f1208e82 100644
--- a/django/contrib/gis/forms/widgets.py
+++ b/django/contrib/gis/forms/widgets.py
@@ -1,11 +1,9 @@
import logging
-from django.conf import settings
from django.contrib.gis import gdal
from django.contrib.gis.geometry import json_regex
from django.contrib.gis.geos import GEOSException, GEOSGeometry
from django.forms.widgets import Widget
-from django.utils import translation
logger = logging.getLogger("django.contrib.gis")
@@ -16,6 +14,7 @@ class BaseGeometryWidget(Widget):
Render a map using the WKT of the geometry.
"""
+ base_layer = None
geom_type = "GEOMETRY"
map_srid = 4326
display_raw = False
@@ -24,9 +23,10 @@ class BaseGeometryWidget(Widget):
template_name = "" # set on subclasses
def __init__(self, attrs=None):
- self.attrs = {}
- for key in ("geom_type", "map_srid", "display_raw"):
- self.attrs[key] = getattr(self, key)
+ self.attrs = {
+ key: getattr(self, key)
+ for key in ("base_layer", "geom_type", "map_srid", "display_raw")
+ }
if attrs:
self.attrs.update(attrs)
@@ -61,26 +61,16 @@ def get_context(self, name, value, attrs):
self.map_srid,
err,
)
-
+ context["serialized"] = self.serialize(value)
geom_type = gdal.OGRGeomType(self.attrs["geom_type"]).name
- context.update(
- self.build_attrs(
- self.attrs,
- {
- "name": name,
- "module": "geodjango_%s" % name.replace("-", "_"), # JS-safe
- "serialized": self.serialize(value),
- "geom_type": "Geometry" if geom_type == "Unknown" else geom_type,
- "STATIC_URL": settings.STATIC_URL,
- "LANGUAGE_BIDI": translation.get_language_bidi(),
- **(attrs or {}),
- },
- )
+ context["widget"]["attrs"]["geom_name"] = (
+ "Geometry" if geom_type == "Unknown" else geom_type
)
return context
class OpenLayersWidget(BaseGeometryWidget):
+ base_layer = "nasaWorldview"
template_name = "gis/openlayers.html"
map_srid = 3857
@@ -112,14 +102,15 @@ class OSMWidget(OpenLayersWidget):
An OpenLayers/OpenStreetMap-based widget.
"""
- template_name = "gis/openlayers-osm.html"
+ base_layer = "osm"
default_lon = 5
default_lat = 47
default_zoom = 12
def __init__(self, attrs=None):
- super().__init__()
- for key in ("default_lon", "default_lat", "default_zoom"):
- self.attrs[key] = getattr(self, key)
- if attrs:
- self.attrs.update(attrs)
+ if attrs is None:
+ attrs = {}
+ attrs.setdefault("default_lon", self.default_lon)
+ attrs.setdefault("default_lat", self.default_lat)
+ attrs.setdefault("default_zoom", self.default_zoom)
+ super().__init__(attrs=attrs)
diff --git a/django/contrib/gis/static/gis/js/OLMapWidget.js b/django/contrib/gis/static/gis/js/OLMapWidget.js
index a545036c9f45..bea4aab863e1 100644
--- a/django/contrib/gis/static/gis/js/OLMapWidget.js
+++ b/django/contrib/gis/static/gis/js/OLMapWidget.js
@@ -58,8 +58,16 @@ class MapWidget {
this.options[property] = options[property];
}
}
- if (!options.base_layer) {
- this.options.base_layer = new ol.layer.Tile({source: new ol.source.OSM()});
+
+ // Options' base_layer can be empty, or contain a layerBuilder key, or
+ // be a layer already constructed.
+ const base_layer = options.base_layer;
+ if (typeof base_layer === 'string' && base_layer in MapWidget.layerBuilder) {
+ this.baseLayer = MapWidget.layerBuilder[base_layer]();
+ } else if (base_layer && typeof base_layer !== 'string') {
+ this.baseLayer = base_layer;
+ } else {
+ this.baseLayer = MapWidget.layerBuilder.osm();
}
this.map = this.createMap();
@@ -120,7 +128,7 @@ class MapWidget {
createMap() {
return new ol.Map({
target: this.options.map_id,
- layers: [this.options.base_layer],
+ layers: [this.baseLayer],
view: new ol.View({
zoom: this.options.default_zoom
})
@@ -231,3 +239,43 @@ class MapWidget {
document.getElementById(this.options.id).value = jsonFormat.writeGeometry(geometry);
}
}
+
+// Static property assignment (ES6-compatible)
+MapWidget.layerBuilder = {
+ nasaWorldview: () => {
+ return new ol.layer.Tile({
+ source: new ol.source.XYZ({
+ attributions: "NASA Worldview",
+ maxZoom: 8,
+ url: "https://map1{a-c}.vis.earthdata.nasa.gov/wmts-webmerc/" +
+ "BlueMarble_ShadedRelief_Bathymetry/default/%7BTime%7D/" +
+ "GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg"
+ })
+ });
+ },
+ osm: () => {
+ return new ol.layer.Tile({source: new ol.source.OSM()});
+ }
+};
+
+function initMapWidgetInSection(section) {
+ const maps = [];
+
+ section.querySelectorAll(".dj_map_wrapper").forEach((wrapper) => {
+ // Avoid initializing map widget on an empty form.
+ if (wrapper.id.includes('__prefix__')) {
+ return;
+ }
+ const options = JSON.parse(wrapper.querySelector("#mapwidget-options").textContent);
+ options.id = wrapper.querySelector("textarea").id;
+ options.map_id = wrapper.querySelector(".dj_map").id;
+ maps.push(new MapWidget(options));
+ });
+
+ return maps;
+};
+
+document.addEventListener("DOMContentLoaded", () => {
+ initMapWidgetInSection(document);
+ document.addEventListener('formset:added', (ev) => {initMapWidgetInSection(ev.target);});
+});
diff --git a/django/contrib/gis/templates/gis/openlayers-osm.html b/django/contrib/gis/templates/gis/openlayers-osm.html
deleted file mode 100644
index 88b1c8c2b69e..000000000000
--- a/django/contrib/gis/templates/gis/openlayers-osm.html
+++ /dev/null
@@ -1,12 +0,0 @@
-{% extends "gis/openlayers.html" %}
-{% load l10n %}
-
-{% block options %}{{ block.super }}
-options['default_lon'] = {{ default_lon|unlocalize }};
-options['default_lat'] = {{ default_lat|unlocalize }};
-options['default_zoom'] = {{ default_zoom|unlocalize }};
-{% endblock %}
-
-{% block base_layer %}
-var base_layer = new ol.layer.Tile({source: new ol.source.OSM()});
-{% endblock %}
diff --git a/django/contrib/gis/templates/gis/openlayers.html b/django/contrib/gis/templates/gis/openlayers.html
index f9f7e5fa5196..80fa57934b6d 100644
--- a/django/contrib/gis/templates/gis/openlayers.html
+++ b/django/contrib/gis/templates/gis/openlayers.html
@@ -1,32 +1,10 @@
-{% load i18n l10n %}
+{% load i18n %}
-<div id="{{ id }}_div_map" class="dj_map_wrapper">
- <div id="{{ id }}_map" class="dj_map"></div>
+<div id="{{ widget.attrs.id }}_div_map" class="dj_map_wrapper">
+ <div id="{{ widget.attrs.id }}_map" class="dj_map"></div>
{% if not disabled %}<span class="clear_features"><a href="">{% translate "Delete all Features" %}</a></span>{% endif %}
- {% if display_raw %}<p>{% translate "Debugging window (serialized value)" %}</p>{% endif %}
- <textarea id="{{ id }}" class="vSerializedField required" cols="150" rows="10" name="{{ name }}"
- {% if not display_raw %} hidden{% endif %}>{{ serialized }}</textarea>
- <script>
- {% block base_layer %}
- var base_layer = new ol.layer.Tile({
- source: new ol.source.XYZ({
- attributions: "NASA Worldview",
- maxZoom: 8,
- url: "https://map1{a-c}.vis.earthdata.nasa.gov/wmts-webmerc/" +
- "BlueMarble_ShadedRelief_Bathymetry/default/%7BTime%7D/" +
- "GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg"
- })
- });
- {% endblock %}
- {% block options %}var options = {
- base_layer: base_layer,
- geom_name: '{{ geom_type }}',
- id: '{{ id }}',
- map_id: '{{ id }}_map',
- map_srid: {{ map_srid|unlocalize }},
- name: '{{ name }}'
- };
- {% endblock %}
- var {{ module }} = new MapWidget(options);
- </script>
+ {% if widget.attrs.display_raw %}<p>{% translate "Debugging window (serialized value)" %}</p>{% endif %}
+ <textarea id="{{ widget.attrs.id }}" class="vSerializedField required" cols="150" rows="10" name="{{ widget.name }}"
+ {% if not widget.attrs.display_raw %} hidden{% endif %}>{{ serialized }}</textarea>
+ {{ widget.attrs|json_script:"mapwidget-options" }}
</div>
diff --git a/docs/ref/contrib/gis/forms-api.txt b/docs/ref/contrib/gis/forms-api.txt
index 61308c593394..c05cef65d098 100644
--- a/docs/ref/contrib/gis/forms-api.txt
+++ b/docs/ref/contrib/gis/forms-api.txt
@@ -96,6 +96,14 @@ Widget attributes
GeoDjango widgets are template-based, so their attributes are mostly different
from other Django widget attributes.
+.. attribute:: BaseGeometryWidget.base_layer
+
+ .. versionadded:: 6.0
+
+ A string that specifies the identifier for the default base map layer to be
+ used by the corresponding JavaScript map widget. It is passed as part of
+ the widget options when rendering, allowing the ``MapWidget`` to determine
+ which map tile provider or base layer to initialize (default is ``None``).
.. attribute:: BaseGeometryWidget.geom_type
@@ -137,15 +145,29 @@ Widget classes
This is an abstract base widget containing the logic needed by subclasses.
You cannot directly use this widget for a geometry field.
- Note that the rendering of GeoDjango widgets is based on a template,
- identified by the :attr:`template_name` class attribute.
+ Note that the rendering of GeoDjango widgets is based on a base layer name,
+ identified by the :attr:`base_layer` class attribute.
``OpenLayersWidget``
.. class:: OpenLayersWidget
- This is the default widget used by all GeoDjango form fields.
- ``template_name`` is ``gis/openlayers.html``.
+ This is the default widget used by all GeoDjango form fields. Attributes
+ are:
+
+ .. attribute:: base_layer
+
+ .. versionadded:: 6.0
+
+ ``nasaWorldview``
+
+ .. attribute:: template_name
+
+ ``gis/openlayers.html``.
+
+ .. attribute:: map_srid
+
+ ``3857``
``OpenLayersWidget`` and :class:`OSMWidget` use the ``ol.js`` file hosted
on the ``cdn.jsdelivr.net`` content-delivery network. You can subclass
@@ -157,12 +179,14 @@ Widget classes
.. class:: OSMWidget
- This widget uses an OpenStreetMap base layer to display geographic objects
- on. Attributes are:
+ This widget specialized :class:`OpenLayersWidget` and uses an OpenStreetMap
+ base layer to display geographic objects on. Attributes are:
- .. attribute:: template_name
+ .. attribute:: base_layer
+
+ .. versionadded:: 6.0
- ``gis/openlayers-osm.html``
+ ``osm``
.. attribute:: default_lat
.. attribute:: default_lon
@@ -179,3 +203,37 @@ Widget classes
tiles.
.. _FAQ answer: https://help.openstreetmap.org/questions/10920/how-to-embed-a-map-in-my-https-site
+
+ .. versionchanged:: 6.0
+
+ The ``OSMWidget`` no longer uses a custom template. Consequently, the
+ ``gis/openlayers-osm.html`` template was removed.
+
+.. _geometry-widgets-customization:
+
+Customizing the base layer used in OpenLayers-based widgets
+-----------------------------------------------------------
+
+.. versionadded:: 6.0
+
+To customize the base layer displayed in OpenLayers-based geometry widgets,
+define a new layer builder in a custom JavaScript file. For example:
+
+.. code-block:: javascript
+ :caption: ``path-to-file.js``
+
+ MapWidget.layerBuilder.custom_layer_name = function () {
+ // Return an OpenLayers layer instance.
+ return new ol.layer.Tile({source: new ol.source.<ChosenSource>()});
+ };
+
+Then, subclass a standard geometry widget and set the ``base_layer``::
+
+ from django.contrib.gis.forms.widgets import OpenLayersWidget
+
+
+ class YourCustomWidget(OpenLayersWidget):
+ base_layer = "custom_layer_name"
+
+ class Media:
+ js = ["path-to-file.js"]
diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt
index ade85a217392..118ad43cc90b 100644
--- a/docs/releases/6.0.txt
+++ b/docs/releases/6.0.txt
@@ -73,6 +73,9 @@ Minor features
function rotates a geometry by a specified angle around the origin or a
specified point.
+* The new :attr:`.BaseGeometryWidget.base_layer` attribute allows specifying a
+ JavaScript map base layer, enabling customization of map tile providers.
+
:mod:`django.contrib.messages`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -332,6 +335,11 @@ Miscellaneous
refactored to use Python's :py:class:`email.message.Message` for parsing.
Input headers exceeding 10000 characters will now raise :exc:`ValueError`.
+* Widgets from :mod:`django.contrib.gis.forms.widgets` now render without
+ inline JavaScript in templates. If you have customized any geometry widgets
+ or their templates, you may need to :ref:`update them
+ <geometry-widgets-customization>` to match the new layout.
+
.. _deprecated-features-6.0:
Features deprecated in 6.0
diff --git a/js_tests/gis/mapwidget.test.js b/js_tests/gis/mapwidget.test.js
index e0cc617a1ed7..723c63d22b34 100644
--- a/js_tests/gis/mapwidget.test.js
+++ b/js_tests/gis/mapwidget.test.js
@@ -1,4 +1,4 @@
-/* global QUnit, MapWidget */
+/* global QUnit, MapWidget, ol */
'use strict';
QUnit.module('gis.OLMapWidget');
@@ -91,3 +91,84 @@ QUnit.test('MapWidget.IsCollection', function(assert) {
widget = new MapWidget(options);
assert.ok(widget.options.is_collection);
});
+
+QUnit.test('MapWidget.layerBuilder.osm returns OSM layer', function(assert) {
+ const layer = MapWidget.layerBuilder.osm();
+ assert.ok(layer instanceof ol.layer.Tile, 'Layer is Tile');
+ assert.ok(layer.getSource() instanceof ol.source.OSM, 'Source is OSM');
+});
+
+QUnit.test('MapWidget.layerBuilder.nasaWorldview returns XYZ layer', function(assert) {
+ const layer = MapWidget.layerBuilder.nasaWorldview();
+ assert.ok(layer instanceof ol.layer.Tile, 'Layer is Tile');
+ assert.ok(layer.getSource() instanceof ol.source.XYZ, 'Source is XYZ');
+ assert.ok(layer.getSource().getUrls()[0].includes('earthdata.nasa.gov'), 'URL is NASA-hosted');
+});
+
+QUnit.test('MapWidget uses default OSM base layer when none specified', function(assert) {
+ const wid
... [truncated]