Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DefaultSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@
'egMapsResizableByDefault' => false,
'egMapsRezoomForKML' => false,

// Boolean. Whether the kml parameter may reference KML outside of this wiki. These documents are
// fetched by the browser of everyone viewing the map, so the host they come from learns the IP
// address and user agent of each reader, and can change what it serves after the edit was
// reviewed. When false, kml values that are not a file on this wiki are dropped, and the
// NetworkLink elements inside a KML document may only point at this wiki. Does not apply to
// gkml, which Google fetches and renders on its own servers.
'egMapsAllowExternalKml' => true,

// Boolean. Sets if pages with maps should be put in special category
'egMapsEnableCategory' => false,

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Settings are grouped by service and topic:
The available groups and keys correspond to `LocalSettings.php` settings:

* **general**: `mapWidth`, `mapHeight`, `defaultTitle`, `defaultLabel`, `resizableByDefault`,
`rezoomForKml`, `pagesWithMapsCategory`, `distanceUnits`, `distanceUnit`, `distanceDecimals`
`rezoomForKml`, `allowExternalKml`, `pagesWithMapsCategory`, `distanceUnits`, `distanceUnit`,
`distanceDecimals`
* **coordinates**: `availableNotations`, `notation`, `directional`
* **geocoding**: `service` (`geonames`, `google` or `nominatim`)
* **semanticMediaWiki**: `showTitle`, `hideNamespace`, `template`, `coordinateFormat`,
Expand Down
7 changes: 7 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ different releases and which versions of PHP and MediaWiki they support, see the
[platform compatibility tables](INSTALL.md#platform-compatibility-and-release-status).


## Maps 14.2.0

Not yet released

* Added the `$egMapsAllowExternalKml` setting, also available as `general.allowExternalKml` on the `MediaWiki:Maps` page. Set it to `false` to have the Google Maps `kml` parameter accept only files on the wiki, and to stop `NetworkLink` elements inside a KML file from pointing anywhere else. It defaults to `true`, which keeps the existing behaviour of letting an editor have the browser of everyone viewing the map fetch KML from any host.
* `NetworkLink` elements in KML files are now followed at most three levels deep, so a KML file that links back to itself no longer makes the browser fetch in an endless loop

## Maps 14.1.1

Released on July 31st, 2026.
Expand Down
8 changes: 8 additions & 0 deletions resources/GoogleMaps/geoxml3/README
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ code of its own:
* geoxml3.js: the NetworkLink refresh schedules a call instead of a string for setInterval to evaluate.
* ProjectedOverlay.js: draw() builds the ground overlay image with the DOM API instead of an HTML string.

A KML document also names further documents for the library to fetch, through NetworkLink and
styleUrl. Two more places were changed to bound which of those the browser retrieves:

* geoxml3.js: the allowExternalDocuments parser option, false on wikis that do not allow KML from
elsewhere, limits every fetch to the origin of the page.
* geoxml3.js: NetworkLink elements are followed at most geoXML3.maxNetworkLinkDepth levels deep, so a
document linking back to itself cannot make the browser fetch forever.

ProjectedOverlay.js additionally exports its constructor on window, because ResourceLoader runs module
scripts inside a function, where the declaration geoxml3.js looks for never becomes global.

Expand Down
61 changes: 58 additions & 3 deletions resources/GoogleMaps/geoxml3/geoxml3.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ if (!String.prototype.trim) {
*/
geoXML3 = window.geoXML3 || {instances: []};

/**
* Local modification (Maps extension): how deeply NetworkLink elements may nest. A NetworkLink can
* point at a document that links back to it, and without a limit the reader's browser would follow
* such a cycle forever.
*/
geoXML3.maxNetworkLinkDepth = 3;

/**
* Local modification (Maps extension): whether a url resolves to the origin of the page, which is
* the wiki itself. Anything the browser cannot resolve counts as another origin.
*/
geoXML3.isSameOrigin = function (url) {
try {
return new URL(url, document.baseURI).origin === window.location.origin;
} catch (e) {
return false;
}
};

/**
* Constructor for the root KML parser object.
*
Expand Down Expand Up @@ -123,6 +142,8 @@ geoXML3.parser = function (options) {
parser: this,
docSet: docSet || [],
remaining: 1,
// Local modification (Maps extension): how many NetworkLink hops led to this document.
depth: 0,
parseOnly: !(parserOptions.afterParse || parserOptions.processStyles)
};
thisDoc = new Object();
Expand All @@ -131,7 +152,21 @@ geoXML3.parser = function (options) {
render(geoXML3.xmlParse(kmlString),thisDoc);
}

var parse = function (urls, docSet) {
// Local modification (Maps extension): the reader's browser does the fetching, so the wiki
// decides whether KML may come from anywhere else. A KML document names documents of its own,
// through NetworkLink and styleUrl, so this is applied everywhere the parser takes a url, not
// only to the ones that came from the wikitext.
var allowedUrl = function (url) {
if (parserOptions.allowExternalDocuments || geoXML3.isSameOrigin(url)) {
return true;
}

geoXML3.log('Not fetching KML from outside this wiki: ' + url);

return false;
};

var parse = function (urls, docSet, depth) {
// Process one or more KML documents
if (!parserName) {
parserName = 'geoXML3.instances[' + (geoXML3.instances.push(this) - 1) + ']';
Expand All @@ -142,11 +177,15 @@ geoXML3.parser = function (options) {
urls = [urls];
}

urls = urls.filter(allowedUrl);

// Internal values for the set of documents as a whole
var internals = {
parser: this,
docSet: docSet || [],
remaining: urls.length,
// Local modification (Maps extension): how many NetworkLink hops led to these documents.
depth: depth || 0,
parseOnly: !(parserOptions.afterParse || parserOptions.processStyles)
};
var thisDoc, j;
Expand Down Expand Up @@ -520,6 +559,7 @@ geoXML3.parser = function (options) {
var rUrl = cleanURL( doc.baseDir, url );
if (rUrl === doc.baseUrl) continue; // self
if (docsByUrl[rUrl]) continue; // already loaded
if (!allowedUrl(rUrl)) continue; // Local modification (Maps extension)

var thisDoc;
var j = docSet.indexOfObjWithItem('baseUrl', rUrl);
Expand Down Expand Up @@ -896,6 +936,11 @@ geoXML3.parser = function (options) {
var docPath = document.location.pathname.split('/');
docPath = docPath.splice(0, docPath.length - 1).join('/');
var linkNodes = getElementsByTagName(responseXML, 'NetworkLink');
// Local modification (Maps extension): stop following NetworkLinks once they have
// nested as deeply as allowed, so a cycle cannot make the browser fetch forever.
if (doc.internals.depth >= geoXML3.maxNetworkLinkDepth) {
linkNodes = [];
}
for (i = 0; i < linkNodes.length; i++) {
node = linkNodes[i];

Expand Down Expand Up @@ -944,12 +989,14 @@ geoXML3.parser = function (options) {
// and the string form of setInterval evaluates it as code. Schedule a call instead,
// like the onChange branch below does.
setInterval(
doc.internals.parser.parse.bind(doc.internals.parser, networkLink.link.href),
doc.internals.parser.parse.bind(
doc.internals.parser, networkLink.link.href, undefined, doc.internals.depth + 1),
1000 * networkLink.link.refreshInterval);
} else if (networkLink.link.refreshMode === 'onChange') {
if (networkLink.link.viewRefreshMode === 'never') {
// Load the link just once
doc.internals.parser.parse(networkLink.link.href, doc.internals.docSet);
doc.internals.parser.parse(
networkLink.link.href, doc.internals.docSet, doc.internals.depth + 1);
} else if (networkLink.link.viewRefreshMode === 'onStop') {
// Reload when the map view changes

Expand Down Expand Up @@ -1491,6 +1538,14 @@ geoXML3.parserOptions = function (overrides) {
this.processStyles = false,
/**#@-*/

/**
* Local modification (Maps extension): when false, the parser only fetches documents from
* the origin of the page, including the ones NetworkLink elements point at.
* @type Boolean
* @default true
*/
this.allowExternalDocuments = true,

this.markerOptions = {},
this.infoWindowOptions = {},
this.overlayOptions = {},
Expand Down
1 change: 1 addition & 0 deletions resources/GoogleMaps/jquery.googlemap.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
var geoXml = new geoXML3.parser({
map:_this.map,
zoom:options.kmlrezoom,
allowExternalDocuments:options.allowexternalkml !== false,
failedParse:function(document){
console.log(options.kml);
console.log(document);
Expand Down
1 change: 1 addition & 0 deletions src/Config/ConfigSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public static function newDefault(): self {
self::replace( 'general', 'defaultLabel', 'egMapsDefaultLabel', new StringType() ),
self::replace( 'general', 'resizableByDefault', 'egMapsResizableByDefault', new BooleanType() ),
self::replace( 'general', 'rezoomForKml', 'egMapsRezoomForKML', new BooleanType() ),
self::replace( 'general', 'allowExternalKml', 'egMapsAllowExternalKml', new BooleanType() ),
self::replace( 'general', 'pagesWithMapsCategory', 'egMapsEnableCategory', new BooleanType() ),
self::replace( 'general', 'distanceUnits', 'egMapsDistanceUnits', new NumberMapType() ),
self::replace( 'general', 'distanceUnit', 'egMapsDistanceUnit', new StringType() ),
Expand Down
6 changes: 5 additions & 1 deletion src/DataAccess/MediaWikiFileUrlFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
class MediaWikiFileUrlFinder implements FileUrlFinder {

public function getUrlForFileName( string $fileName ): string {
return $this->findFileUrl( $fileName ) ?? trim( $fileName );
}

public function findFileUrl( string $fileName ): ?string {
$colonPosition = strpos( $fileName, ':' );

$titleWithoutPrefix = $colonPosition === false ? $fileName : substr( $fileName, $colonPosition + 1 );
Expand All @@ -24,6 +28,6 @@ public function getUrlForFileName( string $fileName ): string {
return $file->getURL();
}

return trim( $fileName );
return null;
}
}
5 changes: 5 additions & 0 deletions src/FileUrlFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ interface FileUrlFinder {
*/
public function getUrlForFileName( string $fileName ): string;

/**
* The url of the file page with this name, or null when this wiki has no such file.
*/
public function findFileUrl( string $fileName ): ?string;

}
61 changes: 44 additions & 17 deletions src/GoogleMapsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ class GoogleMapsService implements MappingService {
];

private EffectiveSettings $config;
private FileUrlFinder $fileUrlFinder;
private $addedDependencies = [];

public function __construct( EffectiveSettings $config ) {
public function __construct( EffectiveSettings $config, FileUrlFinder $fileUrlFinder ) {
$this->config = $config;
$this->fileUrlFinder = $fileUrlFinder;
}

public function getName(): string {
Expand Down Expand Up @@ -214,22 +216,7 @@ public function getParameterInfo(): array {
'default' => [],
'message' => 'maps-par-kml',
'islist' => true,
'post-format' => function( array $kmlFileNames ) {
return array_values(
array_filter(
array_map(
function( string $fileName ) {
return MediaWikiServices::getInstance()->getUrlUtils()->expand(
MapsFunctions::getFileUrl( $fileName ) );
},
$kmlFileNames
),
function( string $fileName ) {
return $fileName !== '';
}
)
);
}
'post-format' => fn( array $kmlFileNames ) => $this->getKmlUrls( $kmlFileNames ),
];

$params['gkml'] = [
Expand Down Expand Up @@ -286,6 +273,42 @@ private function getTypeNames(): array {
return array_keys( self::MAP_TYPES );
}

/**
* @param string[] $kmlFileNames
* @return string[]
*/
private function getKmlUrls( array $kmlFileNames ): array {
$urls = [];

foreach ( $kmlFileNames as $fileName ) {
$url = $this->getKmlUrl( $fileName );

if ( $url !== '' ) {
$urls[] = $url;
}
}

return $urls;
}

/**
* The url the browser should fetch this kml value from. Empty when there is none: the value is
* blank, or it is not a file on this wiki while the wiki does not allow external KML.
*/
private function getKmlUrl( string $fileName ): string {
$url = $this->fileUrlFinder->findFileUrl( $fileName ) ?? $this->getExternalKmlUrl( $fileName );

return (string)MediaWikiServices::getInstance()->getUrlUtils()->expand( $url );
}

private function getExternalKmlUrl( string $fileName ): string {
return $this->allowsExternalKml() ? trim( $fileName ) : '';
}

private function allowsExternalKml(): bool {
return (bool)$this->config->get( 'egMapsAllowExternalKml' );
}

public function newMapId(): string {
static $mapsOnThisPage = 0;

Expand Down Expand Up @@ -383,6 +406,10 @@ private function getParameterWithValue( ProcessedParam $param, $value ) {
}

public function newMapDataFromParameters( array $params ): MapData {
// The browser fetches the documents that NetworkLink elements point at, and only sees the
// urls once it has the KML in hand, so it needs to know the policy itself.
$params['allowexternalkml'] = $this->allowsExternalKml();

return new MapData( $params );
}

Expand Down
5 changes: 4 additions & 1 deletion src/MapsFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ public function getMappingServices(): MappingServices {
}

private function getGoogleMapsService(): GoogleMapsService {
$this->googleService ??= new GoogleMapsService( $this->getEffectiveSettings() );
$this->googleService ??= new GoogleMapsService(
$this->getEffectiveSettings(),
$this->getFileUrlFinder()
);

return $this->googleService;
}
Expand Down
9 changes: 9 additions & 0 deletions tests/Integration/Config/OnWikiConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ public function testWikiGoogleZoomReachesTheRenderedMapData(): void {
$this->assertStringContainsString( htmlspecialchars( '"zoom":3' ), $html );
}

public function testWikiExternalKmlPolicyReachesTheRenderedMapData(): void {
$html = $this->parseWithWikiConfig(
[ 'general' => [ 'allowExternalKml' => false ] ],
'{{#google_maps:kml=https://example.com/points.kml}}'
);

$this->assertStringContainsString( htmlspecialchars( '"kml":[]' ), $html );
}

public function testWikiLeafletLayerDefinitionReachesTheRenderedMapData(): void {
$html = $this->parseWithWikiConfig(
[ 'leaflet' => [ 'layerDefinitions' => [
Expand Down
13 changes: 10 additions & 3 deletions tests/Integration/Parser/GoogleMapsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,22 @@ private function parse( string $textToParse ): string {
return TestFactory::newInstance()->parse( $textToParse );
}

public function testGoogleMapsKmlFiltersInvalidFileNames() {
public function testEmptyKmlEntriesAreDropped() {
$this->assertStringContainsData(
'"kml":["ValidFile.kml"],',
'"kml":["https://example.com/points.kml"],',
$this->parse(
"{{#google_maps:kml=, ,ValidFile.kml ,}}"
"{{#google_maps:kml=, ,https://example.com/points.kml ,}}"
)
);
}

public function testExternalKmlIsAllowedByDefault() {
$this->assertStringContainsData(
'"allowexternalkml":true',
$this->parse( '{{#google_maps:1,1}}' )
);
}

public function testWhenValidZoomIsSpecified_itGetsUsed() {
$this->assertStringContainsData(
'"zoom":5',
Expand Down
28 changes: 28 additions & 0 deletions tests/TestDoubles/InMemoryFileUrlFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare( strict_types = 1 );

namespace Maps\Tests\TestDoubles;

use Maps\FileUrlFinder;

class InMemoryFileUrlFinder implements FileUrlFinder {

/**
* @var array<string, string>
*/
private array $urls = [];

public function addFile( string $fileName, string $url ): void {
$this->urls[$fileName] = $url;
}

public function getUrlForFileName( string $fileName ): string {
return $this->findFileUrl( $fileName ) ?? trim( $fileName );
}

public function findFileUrl( string $fileName ): ?string {
return $this->urls[trim( $fileName )] ?? null;
}

}
Loading
Loading