fix: Escapes OSM tag data in camera popups

camera.php built its JSON by hand with htmlentities(), which leaves
backslashes intact, and the client parsed it with eval() and inserted tag
values into the popup as HTML. A tag value such as <img ...> therefore
became live markup (stored XSS for anyone able to edit OSM data), and a lone
backslash broke parsing of the whole response.

- camera.php now builds the response with json_encode() and returns raw values
- tags named lat, lon, id, count, multi or poly no longer override the
  fields set by the server
- error responses no longer include database error messages
- the client uses JSON.parse() and escapes all keys and values; only
  http(s) URLs are rendered as links/images
This commit is contained in:
Stefan Bethke 2026-11-15 11:36:49 +01:00
commit 100762ea2e
2 changed files with 79 additions and 83 deletions

View file

@ -4,6 +4,17 @@
define('MAX_POINTS_FOR_QUICKHULL', 3000);
// Fields set by this script; OSM tags with these keys are not passed on.
define('RESERVED_KEYS', array('lat', 'lon', 'id', 'count', 'multi', 'poly'));
define('JSON_FLAGS', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
function exitWithError($message) {
header('Content-type: application/json; charset=utf-8');
echo json_encode(array('error' => $message), JSON_FLAGS);
exit;
}
class OsmPoint {
var $id;
var $lat;
@ -179,63 +190,47 @@
|| !array_key_exists('zoom', $_GET)
|| !array_key_exists('width', $_GET)
|| !array_key_exists('height', $_GET)) {
header('Content-type: application/json');
$result = '{"error":"bbox, zoom, width and height parameters are required. '
exitWithError('bbox, zoom, width and height parameters are required. '
.(array_key_exists('bbox', $_GET) ? '' : 'bbox is empty. ')
.(array_key_exists('zoom', $_GET) ? '' : 'zoom is empty. ')
.(array_key_exists('width', $_GET) ? '' : 'width is empty. ')
.(array_key_exists('height', $_GET) ? '' : 'height is empty. ')
.'"}';
echo $result;
exit;
.(array_key_exists('height', $_GET) ? '' : 'height is empty. '));
}
/* Check zoom */
$zoom = $_GET['zoom'];
if ( !is_numeric($zoom)
if ( !is_string($zoom)
|| !is_numeric($zoom)
|| intval($zoom) < 1
|| intval($zoom) > 19) {
header('Content-type: application/json');
$result = '{"error":"unexpected zoom value : '
.htmlentities($zoom)
.'"}';
echo $result;
exit;
exitWithError('unexpected zoom value');
}
/* Check bounds box --> bbox=boundsSW.lng,boundsSW.lat,boundsNE.lng,boundsNE.lat */
if (!is_string($_GET['bbox'])) {
exitWithError('unexpected bbox value');
}
$bbox = explode(',', $_GET['bbox']);
if ( !is_numeric($bbox[0]) || $bbox[0] > 180 // 0 = SW.lng
if ( count($bbox) != 4
|| !is_numeric($bbox[0]) || $bbox[0] > 180 // 0 = SW.lng
|| !is_numeric($bbox[2]) || $bbox[2] < -180 // 2 = NE.lng
|| !is_numeric($bbox[1]) || $bbox[1] < -90 || $bbox[1] > 90 // 1 = SW.lat
|| !is_numeric($bbox[3]) || $bbox[3] < -90 || $bbox[3] > 90) { // 3 = NE.lat
header('Content-type: application/json');
$result = '{"error":"unexpected bbox longitude and latitude values : '
.'lon ['.htmlentities($bbox[0]).', '.htmlentities($bbox[2]).'], '
.'lat ['.htmlentities($bbox[1]).', '.htmlentities($bbox[3]).']'
.'"}';
echo $result;
exit;
exitWithError('unexpected bbox longitude and latitude values : '
.'lon ['.$bbox[0].', '.$bbox[2].'], '
.'lat ['.$bbox[1].', '.$bbox[3].']');
}
if ($bbox[0] >= $bbox[2]) { // 0 = SW.lng | 2 = NE.lng
header('Content-type: application/json');
$result = '{"error":"min longitude greater than max longitude : '
.'lon ['.htmlentities($bbox[0]).', '.htmlentities($bbox[2]).']'
.'"}';
echo $result;
exit;
exitWithError('min longitude greater than max longitude : '
.'lon ['.$bbox[0].', '.$bbox[2].']');
}
if ($bbox[1] >= $bbox[3]) { // 1 = SW.lat | 3 = NE.lat
header('Content-type: application/json');
$result = '{"error":"min latitude greater than max latitude : '
.'lat ['.htmlentities($bbox[1]).', '.htmlentities($bbox[3]).']'
.'"}';
echo $result;
exit;
exitWithError('min latitude greater than max latitude : '
.'lat ['.$bbox[1].', '.$bbox[3].']');
}
$lonMin = $bbox[0]; // 0 = SW.lng
@ -247,12 +242,7 @@
$pixelHeight = (int) $_GET['height'];
if ($pixelWidth == 0 || $pixelHeight == 0) {
header('Content-type: application/json');
$result = '{"error":"width or height is null : '
.htmlentities($pixelWidth).'x'.htmlentities($pixelHeight)
.'"}';
echo $result;
exit;
exitWithError('width or height is null : '.$pixelWidth.'x'.$pixelHeight);
}
$lonWidth = $lonMax - $lonMin; // NE.lng - SW.lng
@ -268,10 +258,7 @@
/* Connect to database */
$mysqli = new mysqli(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB);
if($mysqli->connect_errno) {
header('Content-type: application/json');
$result = '{"error":"error while connecting to db : ' . $mysqli->error . '"}';
echo $result;
exit;
exitWithError('error while connecting to db');
}
$rqtLonMin = $lonMin; // SW.lng
@ -365,10 +352,7 @@
$stmt->close();
} else {
$mysqli->close();
header('Content-type: application/json');
$result = '{"error":"Error while request : ' . $mysqli->error . '"}';
echo $result;
exit;
exitWithError('error while querying db');
}
/* Unify some clusters if nodes center are near */
@ -380,8 +364,7 @@
}
}
$result='[';
$separator='';
$result = array();
/* Writing selected points */
$sql='SELECT k, v
@ -393,53 +376,49 @@
$stmt->bind_param('d', $id);
$stmt->bind_result($k, $v);
/* Writing grouped items or not */
/* Writing grouped items or not. All values are passed on unescaped; the client
must escape them before inserting them into HTML. */
foreach($resArray as $val) {
if ($val['count'] > 0) {
$result = $result.$separator
.'{"lat":"'.($val['latitude'] / $val['count']).'"'
.',"lon":"'.($val['longitude'] / $val['count']).'"';
$entry = array(
'lat' => (string) ($val['latitude'] / $val['count']),
'lon' => (string) ($val['longitude'] / $val['count']));
if ($val['count'] == 1) {
$id = $val['points'][0]->id;
$result = $result.',"id":"'.$id.'"';
$entry['id'] = (string) $id;
$stmt->execute();
while($stmt->fetch()) {
$result = $result.',"'.htmlentities($k).'":"'.htmlentities($v, ENT_COMPAT, 'UTF-8').'"';
if (!in_array($k, RESERVED_KEYS, true)) {
$entry[$k] = (string) $v;
}
}
} else {
$result = $result
.',"count":"'.$val['count'].'"'
.',"multi":"yes"'
.',"poly":[';
$entry['count'] = (string) $val['count'];
$entry['multi'] = 'yes';
$entry['poly'] = array();
if ($pointsCount < MAX_POINTS_FOR_QUICKHULL) {
$convexPoly = quickHull($val['points'], $val['count']);
$sepPoly = '';
foreach($convexPoly as $point) {
$result = $result.$sepPoly.'{'
.'"lat":"'.$point->lat.'"'
.',"id":"'.$point->id.'"'
.',"lon":"'.$point->lon.'"}';
$sepPoly=',';
$entry['poly'][] = array(
'lat' => (string) $point->lat,
'id' => (string) $point->id,
'lon' => (string) $point->lon);
}
}
$result=$result.']';
}
$result = $result.'}';
$separator=',';
$result[] = $entry;
}
}
$stmt->close();
$result = $result . ']';
$mysqli->close();
header('Content-type: application/json; Charset : utf-8');
echo $result;
header('Content-type: application/json; charset=utf-8');
echo json_encode($result, JSON_FLAGS);
?>

View file

@ -129,7 +129,11 @@ function onClick(e) {
function onStateChanged() {
if (ajaxRequest.readyState == 4) { // 0 = UNSENT, 1 = OPENED, 2 = HEADERS_RECEIVED, 3 = LOADING, 4 = DONE
if (ajaxRequest.status == 200) { // 200 = OK, 404 = Page not found
plotList = eval('(' + ajaxRequest.responseText + ')');
try {
plotList = JSON.parse(ajaxRequest.responseText);
} catch(e) {
return;
}
removeMarkers();
for (i=0; i<plotList.length; i++) {
var plotLatLng;
@ -417,26 +421,39 @@ function drawCameraFocusDome(plotLatLng, focusRadius, focusColor) {
return(new L.Circle(plotLatLng, focusRadius, { weight:1, color:focusColor, fillOpacity:0.1 }));
}
// Escape a value for use in HTML text and in double-quoted attributes.
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Add camera popup to camera marker.
// All values in plot come unescaped from OSM data and must be escaped.
function addCameraDetailsData(plotMarker, plot) {
var id = escapeHtml(plot.id);
popupDataTable = '<table class="popup-content">'
+ '<tr><td>id</td><td><a href="https://www.openstreetmap.org/node/' + (plot.id) + '">' + (plot.id) + '</a></td></tr>'
// + '<tr><td>user osm</td><td>' + (plot.userid) + '</td></tr>'
+ '<tr><td>latitude</td><td>' + (plot.lat) + '</td></tr>'
+ '<tr><td>longitude</td><td>' + (plot.lon) + '</td></tr>';
+ '<tr><td>id</td><td><a href="https://www.openstreetmap.org/node/' + encodeURIComponent(plot.id) + '">' + id + '</a></td></tr>'
// + '<tr><td>user osm</td><td>' + escapeHtml(plot.userid) + '</td></tr>'
+ '<tr><td>latitude</td><td>' + escapeHtml(plot.lat) + '</td></tr>'
+ '<tr><td>longitude</td><td>' + escapeHtml(plot.lon) + '</td></tr>';
for (x in plot) {
if (plot[x] !== '' && x != 'multi' && x != 'id' && x != 'userid' && x != 'lat' && x != 'lon') {
popupDataTable = popupDataTable + '<tr><td>' + x + '</td><td>';
var descr = plot[x];
if (descr.substr(0, 4) == 'http') {
popupDataTable = popupDataTable + '<tr><td>' + escapeHtml(x) + '</td><td>';
var descr = String(plot[x]);
if (/^https?:\/\//i.test(descr)) {
var url = escapeHtml(descr);
var suffix = descr.slice(-3).toLowerCase();
if (suffix == 'jpg' || suffix == 'gif' || suffix == 'png') {
popupDataTable = popupDataTable + '<a href="' + descr + '"><img alt="Link" src="' + descr + '" width="200"/></a>';
popupDataTable = popupDataTable + '<a href="' + url + '"><img alt="Link" src="' + url + '" width="200"/></a>';
} else {
popupDataTable = popupDataTable + '<a href="' + descr + '">Link</a>';
popupDataTable = popupDataTable + '<a href="' + url + '">Link</a>';
}
} else {
popupDataTable = popupDataTable + plot[x];
popupDataTable = popupDataTable + escapeHtml(descr);
}
popupDataTable = popupDataTable + '</td></tr>';
}