Legend images: request them with CORS, and fall back to a same-origin proxy
The legend image was cancelled by the browser: "violates the resource's Cross-Origin-Resource-Policy response header". The app is cross-origin isolated — index.php and the container both send Cross-Origin-Embedder-Policy: require-corp, which SQLite in a worker needs — and under that policy a cross-origin image is blocked unless the provider sends Cross-Origin-Resource-Policy. Digital Earth Africa does not: checked by HEAD and GET, both origins, with and without a query string, always 200 with access-control-allow-origin: * and no CORP. A CORS request satisfies COEP on its own, which is why the base maps have always worked — every tile source in MapView.js already sets crossOrigin: 'anonymous', and OpenTopoMap sends only ACAO too. The legend <img> was the one cross-origin request in the app that did not ask for CORS. It now does, and the element is built through the DOM rather than innerHTML so the URL is not interpolated into an attribute. Because the browser I could test in still refused that particular host at the network layer — net::ERR_FAILED with no response, which curl never reproduces from the same machine — this also adds legend.php, a same-origin fallback. The client tries the provider directly first and falls back once; only if both fail is the entry hidden. legend.php is deliberately narrow, because it runs inside the Docker network where it can reach ApiEngine, MainBackend and the database. Hosts are allow-listed, the resolved address is rejected if it falls in a private or reserved range, redirects are not followed, the body is capped at 2 MB and must be an image, and nothing about the request is reflected back. Verified live: the allow-listed legend returns a 4,701-byte PNG, while ApiEngine, MainBackend, 169.254.169.254, loopback, an unlisted host and file:// are all refused, as is any method other than GET. Legend URLs come from the application's own layer definitions, never from user input, so the allow-list restricts nothing today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
577d94ef5b
commit
b7e79dc778
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-BZOjrkLV.js.map
vendored
1
dist/assets/index-BZOjrkLV.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-DY549L0n.js.map
vendored
Normal file
1
dist/assets/index-DY549L0n.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,4 +1,4 @@
|
||||
import{d as f,z as d}from"./index-BZOjrkLV.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
import{d as f,z as d}from"./index-DY549L0n.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
@ -27,4 +27,4 @@ ${n.map(t=>`<Relationship Id="rId${t.id}" Type="http://schemas.openxmlformats.or
|
||||
`)}
|
||||
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx};
|
||||
//# sourceMappingURL=xlsx-q0F9FUtJ.js.map
|
||||
//# sourceMappingURL=xlsx-Xgu_2_hC.js.map
|
||||
File diff suppressed because one or more lines are too long
2
dist/index.html
vendored
2
dist/index.html
vendored
@ -2004,7 +2004,7 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-BZOjrkLV.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DY549L0n.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">
|
||||
|
||||
127
dist/legend.php
vendored
Normal file
127
dist/legend.php
vendored
Normal file
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
/**
|
||||
* Same-origin proxy for layer legend images.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* The application is served cross-origin isolated — index.php and the container
|
||||
* both send `Cross-Origin-Embedder-Policy: require-corp`, which SQLite in a
|
||||
* worker needs. Under that policy a cross-origin image is blocked unless the
|
||||
* provider sends `Cross-Origin-Resource-Policy`, and most OGC servers do not.
|
||||
* A CORS request satisfies COEP on its own, so the client tries the provider
|
||||
* directly first (see _registerLegend in MapView.js); this proxy is the
|
||||
* fallback for providers where that still fails. Fetching the image here makes
|
||||
* it same-origin, which sidesteps the policy entirely.
|
||||
*
|
||||
* SECURITY
|
||||
* This script runs inside the Docker network, where it can reach ApiEngine,
|
||||
* MainBackend and the database — none of which are reachable from outside. An
|
||||
* open `?url=` proxy would therefore be a server-side request forgery hole
|
||||
* straight into them. Hosts are allow-listed, and the resolved address is
|
||||
* checked against private and reserved ranges as a second line of defence, so
|
||||
* a DNS entry that later points inward cannot be used either. Redirects are not
|
||||
* followed, because a 302 to an internal host would defeat both checks.
|
||||
*
|
||||
* Legend URLs come from the application's own layer definitions in main.js,
|
||||
* never from user input, so the allow-list costs nothing today. Adding a host
|
||||
* is a deliberate decision: it grants that host the right to have its content
|
||||
* served from our origin.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Hosts whose legend images may be proxied. */
|
||||
const ALLOWED_HOSTS = [
|
||||
'ows.digitalearth.africa',
|
||||
];
|
||||
|
||||
const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap
|
||||
const TIMEOUT_S = 10;
|
||||
|
||||
/**
|
||||
* Refuse without saying why, and without reflecting the requested URL back.
|
||||
*
|
||||
* @param int $code
|
||||
* @return void
|
||||
*/
|
||||
function deny($code = 404)
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo "Legend not available\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
deny(405);
|
||||
}
|
||||
|
||||
$raw = isset($_GET['url']) ? (string) $_GET['url'] : '';
|
||||
if ($raw === '' || strlen($raw) > 2048) {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$parts = parse_url($raw);
|
||||
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$host = strtolower($parts['host']);
|
||||
if (!in_array($host, ALLOWED_HOSTS, true)) {
|
||||
deny(403);
|
||||
}
|
||||
|
||||
// Second line of defence: even an allow-listed name must not resolve inward.
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip === $host) {
|
||||
deny(502); // name did not resolve
|
||||
}
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
deny(403); // resolves to a private or reserved address
|
||||
}
|
||||
|
||||
$ch = curl_init($raw);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false, // a redirect could point inward
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => TIMEOUT_S,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_USERAGENT => 'LUPMIS2-legend-proxy/1.0',
|
||||
CURLOPT_ACCEPT_ENCODING => '',
|
||||
// Stop reading once the cap is passed, rather than buffering a huge body.
|
||||
CURLOPT_BUFFERSIZE => 16384,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_PROGRESSFUNCTION => function ($res, $dlTotal, $dlNow) {
|
||||
return ($dlTotal > MAX_BYTES || $dlNow > MAX_BYTES) ? 1 : 0;
|
||||
},
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $status !== 200) {
|
||||
deny(502);
|
||||
}
|
||||
if (strlen($body) > MAX_BYTES) {
|
||||
deny(502);
|
||||
}
|
||||
// Only ever hand back an image, whatever the upstream claims to be.
|
||||
if (stripos($type, 'image/') !== 0) {
|
||||
deny(502);
|
||||
}
|
||||
|
||||
$clean = explode(';', $type)[0];
|
||||
header('Content-Type: ' . trim($clean));
|
||||
header('Content-Length: ' . strlen($body));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
echo $body;
|
||||
127
public/legend.php
Normal file
127
public/legend.php
Normal file
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
/**
|
||||
* Same-origin proxy for layer legend images.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* The application is served cross-origin isolated — index.php and the container
|
||||
* both send `Cross-Origin-Embedder-Policy: require-corp`, which SQLite in a
|
||||
* worker needs. Under that policy a cross-origin image is blocked unless the
|
||||
* provider sends `Cross-Origin-Resource-Policy`, and most OGC servers do not.
|
||||
* A CORS request satisfies COEP on its own, so the client tries the provider
|
||||
* directly first (see _registerLegend in MapView.js); this proxy is the
|
||||
* fallback for providers where that still fails. Fetching the image here makes
|
||||
* it same-origin, which sidesteps the policy entirely.
|
||||
*
|
||||
* SECURITY
|
||||
* This script runs inside the Docker network, where it can reach ApiEngine,
|
||||
* MainBackend and the database — none of which are reachable from outside. An
|
||||
* open `?url=` proxy would therefore be a server-side request forgery hole
|
||||
* straight into them. Hosts are allow-listed, and the resolved address is
|
||||
* checked against private and reserved ranges as a second line of defence, so
|
||||
* a DNS entry that later points inward cannot be used either. Redirects are not
|
||||
* followed, because a 302 to an internal host would defeat both checks.
|
||||
*
|
||||
* Legend URLs come from the application's own layer definitions in main.js,
|
||||
* never from user input, so the allow-list costs nothing today. Adding a host
|
||||
* is a deliberate decision: it grants that host the right to have its content
|
||||
* served from our origin.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Hosts whose legend images may be proxied. */
|
||||
const ALLOWED_HOSTS = [
|
||||
'ows.digitalearth.africa',
|
||||
];
|
||||
|
||||
const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap
|
||||
const TIMEOUT_S = 10;
|
||||
|
||||
/**
|
||||
* Refuse without saying why, and without reflecting the requested URL back.
|
||||
*
|
||||
* @param int $code
|
||||
* @return void
|
||||
*/
|
||||
function deny($code = 404)
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo "Legend not available\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
deny(405);
|
||||
}
|
||||
|
||||
$raw = isset($_GET['url']) ? (string) $_GET['url'] : '';
|
||||
if ($raw === '' || strlen($raw) > 2048) {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$parts = parse_url($raw);
|
||||
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||
deny(400);
|
||||
}
|
||||
|
||||
$host = strtolower($parts['host']);
|
||||
if (!in_array($host, ALLOWED_HOSTS, true)) {
|
||||
deny(403);
|
||||
}
|
||||
|
||||
// Second line of defence: even an allow-listed name must not resolve inward.
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip === $host) {
|
||||
deny(502); // name did not resolve
|
||||
}
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
deny(403); // resolves to a private or reserved address
|
||||
}
|
||||
|
||||
$ch = curl_init($raw);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false, // a redirect could point inward
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => TIMEOUT_S,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_USERAGENT => 'LUPMIS2-legend-proxy/1.0',
|
||||
CURLOPT_ACCEPT_ENCODING => '',
|
||||
// Stop reading once the cap is passed, rather than buffering a huge body.
|
||||
CURLOPT_BUFFERSIZE => 16384,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_PROGRESSFUNCTION => function ($res, $dlTotal, $dlNow) {
|
||||
return ($dlTotal > MAX_BYTES || $dlNow > MAX_BYTES) ? 1 : 0;
|
||||
},
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $status !== 200) {
|
||||
deny(502);
|
||||
}
|
||||
if (strlen($body) > MAX_BYTES) {
|
||||
deny(502);
|
||||
}
|
||||
// Only ever hand back an image, whatever the upstream claims to be.
|
||||
if (stripos($type, 'image/') !== 0) {
|
||||
deny(502);
|
||||
}
|
||||
|
||||
$clean = explode(';', $type)[0];
|
||||
header('Content-Type: ' . trim($clean));
|
||||
header('Content-Length: ' . strlen($body));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
echo $body;
|
||||
@ -5203,14 +5203,36 @@ export class MapView {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'map-legend-entry';
|
||||
wrapper.style.cssText = 'border-bottom:1px solid var(--border, #1e1a4b1f);padding-bottom:6px;';
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-weight:600;font-size:11px;margin-bottom:4px;line-height:1.3;">
|
||||
${this._escapeHtml(title)}
|
||||
</div>
|
||||
<img src="${legendUrl}" alt="${this._escapeHtml(title)} legend"
|
||||
style="display:block;max-width:100%;height:auto;border-radius:3px;"
|
||||
onerror="this.style.display='none'" />
|
||||
`;
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.style.cssText = 'font-weight:600;font-size:11px;margin-bottom:4px;line-height:1.3;';
|
||||
heading.textContent = title;
|
||||
wrapper.appendChild(heading);
|
||||
|
||||
// Loading a legend here is not as simple as setting src. The app is
|
||||
// cross-origin isolated (Cross-Origin-Embedder-Policy: require-corp), so a
|
||||
// cross-origin image is blocked unless the provider sends
|
||||
// Cross-Origin-Resource-Policy — which most OGC servers do not. A CORS
|
||||
// request satisfies COEP on its own, so try the provider directly with
|
||||
// crossorigin first: that is how the base-map tiles already work. If it
|
||||
// still fails, fall back once to /legend.php, which fetches the image
|
||||
// server-side and serves it from our own origin, where no cross-origin
|
||||
// policy applies. Only after both fail is the entry hidden.
|
||||
const img = document.createElement('img');
|
||||
img.alt = `${title} legend`;
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.style.cssText = 'display:block;max-width:100%;height:auto;border-radius:3px;';
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.viaProxy === '1') {
|
||||
img.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
img.dataset.viaProxy = '1';
|
||||
img.removeAttribute('crossorigin'); // same-origin from here, so CORS is moot
|
||||
img.src = `/legend.php?url=${encodeURIComponent(legendUrl)}`;
|
||||
});
|
||||
img.src = legendUrl;
|
||||
wrapper.appendChild(img);
|
||||
|
||||
this._legendEntries.set(layer, wrapper);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user