@seed-ship/mcp-ui-solid 6.9.0 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/components/MapRenderer.cjs +73 -38
- package/dist/components/MapRenderer.cjs.map +1 -1
- package/dist/components/MapRenderer.d.ts +42 -1
- package/dist/components/MapRenderer.d.ts.map +1 -1
- package/dist/components/MapRenderer.js +74 -39
- package/dist/components/MapRenderer.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +2 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +2 -1
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/package.json +1 -1
- package/src/components/MapRenderer.markers.test.ts +68 -0
- package/src/components/MapRenderer.security.test.ts +83 -0
- package/src/components/MapRenderer.tsx +95 -11
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.11.0 — map hardening.
|
|
3
|
+
*
|
|
4
|
+
* Covers the marker tooltip/popup escaping wiring (audit P1.2 completion: the
|
|
5
|
+
* `bindMarkerContent` helper shipped in v6.10.0 but the marker loops still
|
|
6
|
+
* bound raw HTML until v6.11.0) and the `popupSafeText` contract reused by the
|
|
7
|
+
* PMTiles-failure path (P1.3).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { bindMarkerContent, popupSafeText } from './MapRenderer'
|
|
12
|
+
|
|
13
|
+
const XSS = '<img src=x onerror=alert(1)>'
|
|
14
|
+
|
|
15
|
+
// Minimal Leaflet marker stub recording what gets bound.
|
|
16
|
+
function fakeMarker() {
|
|
17
|
+
const calls: { tooltip?: string; popup?: string } = {}
|
|
18
|
+
return {
|
|
19
|
+
calls,
|
|
20
|
+
bindTooltip(html: string) {
|
|
21
|
+
calls.tooltip = html
|
|
22
|
+
return this
|
|
23
|
+
},
|
|
24
|
+
bindPopup(html: string) {
|
|
25
|
+
calls.popup = html
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('bindMarkerContent (audit P1.2 wiring)', () => {
|
|
32
|
+
it('escapes marker tooltip/popup by default (untrusted host)', () => {
|
|
33
|
+
const m = fakeMarker()
|
|
34
|
+
bindMarkerContent(m, { tooltip: XSS, popup: XSS }, false)
|
|
35
|
+
expect(m.calls.tooltip).not.toContain('<img')
|
|
36
|
+
expect(m.calls.tooltip).toContain('<img')
|
|
37
|
+
expect(m.calls.popup).not.toContain('<img')
|
|
38
|
+
expect(m.calls.popup).toContain('<img')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes raw HTML through only when the host opts in (allowHtml=true)', () => {
|
|
42
|
+
const m = fakeMarker()
|
|
43
|
+
bindMarkerContent(m, { tooltip: XSS, popup: XSS }, true)
|
|
44
|
+
expect(m.calls.tooltip).toBe(XSS)
|
|
45
|
+
expect(m.calls.popup).toBe(XSS)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('binds nothing when tooltip/popup are absent', () => {
|
|
49
|
+
const m = fakeMarker()
|
|
50
|
+
bindMarkerContent(m, {}, false)
|
|
51
|
+
expect(m.calls.tooltip).toBeUndefined()
|
|
52
|
+
expect(m.calls.popup).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('popupSafeText', () => {
|
|
57
|
+
it('escapes by default', () => {
|
|
58
|
+
expect(popupSafeText(XSS)).toBe('<img src=x onerror=alert(1)>')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('is identity when the host trusts the payload', () => {
|
|
62
|
+
expect(popupSafeText(XSS, true)).toBe(XSS)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns undefined for missing values', () => {
|
|
66
|
+
expect(popupSafeText(undefined)).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MapRenderer XSS hardening tests (audit P1.2, v6.10.0).
|
|
3
|
+
*
|
|
4
|
+
* Leaflet renders bound tooltip/popup strings as HTML, so untrusted payload
|
|
5
|
+
* content (`marker.tooltip`, `marker.popup`, GeoJSON `popup.template`) is an
|
|
6
|
+
* XSS vector. These lock the safe-by-default behavior and the host opt-in,
|
|
7
|
+
* via the pure exported helpers (the Leaflet binding itself is a thin wrapper
|
|
8
|
+
* and not exercisable in jsdom).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { popupSafeText, buildPopupContent } from './MapRenderer';
|
|
13
|
+
|
|
14
|
+
const XSS = '<img src=x onerror=alert(1)>';
|
|
15
|
+
|
|
16
|
+
describe('popupSafeText — marker tooltip/popup (P1.2)', () => {
|
|
17
|
+
it('escapes HTML by default (untrusted payload path)', () => {
|
|
18
|
+
const out = popupSafeText(XSS);
|
|
19
|
+
expect(out).toBe('<img src=x onerror=alert(1)>');
|
|
20
|
+
expect(out).not.toContain('<img');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('escapes < > & " so no tag can be injected', () => {
|
|
24
|
+
expect(popupSafeText('a & b <c> "d"')).toBe('a & b <c> "d"');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('passes raw HTML through when the host opts in (trusted)', () => {
|
|
28
|
+
expect(popupSafeText(XSS, true)).toBe(XSS);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns undefined for absent content', () => {
|
|
32
|
+
expect(popupSafeText(undefined)).toBeUndefined();
|
|
33
|
+
expect(popupSafeText(undefined, true)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('leaves plain text visually unchanged', () => {
|
|
37
|
+
expect(popupSafeText('Paris')).toBe('Paris');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('buildPopupContent — GeoJSON popup (P1.2)', () => {
|
|
42
|
+
const feature = (props: Record<string, unknown>) => ({ properties: props });
|
|
43
|
+
|
|
44
|
+
it('ignores a raw `popup.template` on the default (untrusted) path', () => {
|
|
45
|
+
const html = buildPopupContent(
|
|
46
|
+
feature({ name: 'Zone' }),
|
|
47
|
+
{ template: `<b>{{name}}</b><img src=x onerror=alert(1)>` }
|
|
48
|
+
// allowHtml defaults to false
|
|
49
|
+
);
|
|
50
|
+
// Template skipped → falls through to the auto popup (no <img>, no <b>).
|
|
51
|
+
expect(html).not.toContain('<img');
|
|
52
|
+
expect(html).not.toContain('<b>');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('honors `popup.template` when the host opts in, but escapes substituted values', () => {
|
|
56
|
+
const html = buildPopupContent(
|
|
57
|
+
feature({ name: '<script>evil</script>' }),
|
|
58
|
+
{ template: '<b>{{name}}</b>' },
|
|
59
|
+
true
|
|
60
|
+
);
|
|
61
|
+
// The authored structural HTML stays; the data value is escaped.
|
|
62
|
+
expect(html).toContain('<b>');
|
|
63
|
+
expect(html).toContain('<script>');
|
|
64
|
+
expect(html).not.toContain('<script>');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('auto-generated popup always escapes values (safe by construction)', () => {
|
|
68
|
+
const html = buildPopupContent(
|
|
69
|
+
feature({ title: XSS, count: 5 }),
|
|
70
|
+
{ titleField: 'title', fields: ['count'] }
|
|
71
|
+
// default untrusted path
|
|
72
|
+
);
|
|
73
|
+
expect(html).not.toContain('<img');
|
|
74
|
+
expect(html).toContain('<img');
|
|
75
|
+
// structural HTML authored by the renderer is present
|
|
76
|
+
expect(html).toContain('<strong>');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns null when there is no popup config or no properties', () => {
|
|
80
|
+
expect(buildPopupContent({ properties: { a: 1 } }, undefined)).toBeNull();
|
|
81
|
+
expect(buildPopupContent({}, { titleField: 'a' })).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -41,6 +41,25 @@ export interface MapRendererProps {
|
|
|
41
41
|
* @see ExpandableWrapperProps.toolbarVariant
|
|
42
42
|
*/
|
|
43
43
|
toolbarVariant?: 'hover' | 'always-visible';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Trust marker / popup content as raw HTML (v6.10.0, audit P1.2).
|
|
47
|
+
*
|
|
48
|
+
* Leaflet renders bound tooltip/popup strings as HTML, so `marker.tooltip`,
|
|
49
|
+
* `marker.popup` and a GeoJSON `popup.template` are XSS vectors when the
|
|
50
|
+
* payload is untrusted (the default for an LLM/connector-driven public
|
|
51
|
+
* package). **Default `false`** → those are escaped as plain text, and
|
|
52
|
+
* `popup.template` is ignored in favour of the safe auto-generated popup.
|
|
53
|
+
*
|
|
54
|
+
* This is a **host-level** trust decision, deliberately NOT a payload field
|
|
55
|
+
* (a malicious payload could just set its own flag). The
|
|
56
|
+
* `<UIResourceRenderer>` path never sets it, so payload-driven maps are
|
|
57
|
+
* always text-safe. A host rendering `<MapRenderer>` directly with trusted
|
|
58
|
+
* data may set `allowHtmlPopups` to restore rich HTML popups. The
|
|
59
|
+
* auto-generated popup (`titleField` / `fields`) is safe-by-construction
|
|
60
|
+
* and unaffected either way.
|
|
61
|
+
*/
|
|
62
|
+
allowHtmlPopups?: boolean;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
// ─── Helpers ────────────────────────────────────────────────
|
|
@@ -108,16 +127,27 @@ function buildStyleFn(
|
|
|
108
127
|
|
|
109
128
|
/**
|
|
110
129
|
* Build popup HTML from a feature's properties using popup config.
|
|
130
|
+
*
|
|
131
|
+
* `allowHtml` (audit P1.2) gates the raw-HTML `popup.template`: it is honored
|
|
132
|
+
* only when the host trusts the payload. On the default (untrusted) path the
|
|
133
|
+
* template is ignored and we fall through to the auto-generated popup, whose
|
|
134
|
+
* structure is renderer-authored and whose values are always escaped — safe
|
|
135
|
+
* by construction. Even in the trusted path, substituted `{{prop}}` values
|
|
136
|
+
* are escaped (they are data, not markup).
|
|
111
137
|
*/
|
|
112
|
-
function buildPopupContent(
|
|
138
|
+
export function buildPopupContent(
|
|
139
|
+
feature: any,
|
|
140
|
+
popup: MapPopupConfig | undefined,
|
|
141
|
+
allowHtml = false
|
|
142
|
+
): string | null {
|
|
113
143
|
if (!popup || !feature?.properties) return null;
|
|
114
144
|
const props = feature.properties;
|
|
115
145
|
|
|
116
|
-
// Custom template
|
|
117
|
-
if (popup.template) {
|
|
146
|
+
// Custom template — raw HTML, so trusted-host only.
|
|
147
|
+
if (allowHtml && popup.template) {
|
|
118
148
|
return popup.template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
119
149
|
const val = props[key];
|
|
120
|
-
return val != null ? String(val) : '';
|
|
150
|
+
return val != null ? escapeHtml(String(val)) : '';
|
|
121
151
|
});
|
|
122
152
|
}
|
|
123
153
|
|
|
@@ -150,6 +180,29 @@ function escapeHtml(str: string): string {
|
|
|
150
180
|
.replace(/"/g, '"');
|
|
151
181
|
}
|
|
152
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a marker's tooltip/popup string for binding (audit P1.2). Leaflet
|
|
185
|
+
* renders bound strings as HTML; `marker.tooltip` / `marker.popup` come from
|
|
186
|
+
* the (untrusted) payload, so they are escaped to plain text unless the host
|
|
187
|
+
* has opted into raw HTML via `allowHtmlPopups`. Pure + exported for tests.
|
|
188
|
+
*/
|
|
189
|
+
export function popupSafeText(value: string | undefined, allowHtml = false): string | undefined {
|
|
190
|
+
if (value == null) return undefined;
|
|
191
|
+
return allowHtml ? value : escapeHtml(value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Bind a marker's tooltip + popup, escaping by default (see popupSafeText). */
|
|
195
|
+
export function bindMarkerContent(
|
|
196
|
+
m: any,
|
|
197
|
+
marker: { tooltip?: string; popup?: string },
|
|
198
|
+
allowHtml: boolean
|
|
199
|
+
): void {
|
|
200
|
+
const tooltip = popupSafeText(marker.tooltip, allowHtml);
|
|
201
|
+
if (tooltip) m.bindTooltip(tooltip);
|
|
202
|
+
const popup = popupSafeText(marker.popup, allowHtml);
|
|
203
|
+
if (popup) m.bindPopup(popup);
|
|
204
|
+
}
|
|
205
|
+
|
|
153
206
|
/**
|
|
154
207
|
* Add a GeoJSON layer to the map with style and popup support.
|
|
155
208
|
* Returns the layer for bounds calculation.
|
|
@@ -223,8 +276,15 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
223
276
|
let mapInstance: any = null;
|
|
224
277
|
const [isLeafletLoaded, setIsLeafletLoaded] = createSignal(false);
|
|
225
278
|
const [error, setError] = createSignal<string | null>(null);
|
|
279
|
+
// PMTiles is an optional overlay on top of the base map. When it fails we
|
|
280
|
+
// keep the base map but surface a visible notice (audit P1.3) instead of a
|
|
281
|
+
// silent console.warn that leaves the user with an unexplained empty layer.
|
|
282
|
+
const [pmtilesError, setPmtilesError] = createSignal<string | null>(null);
|
|
226
283
|
const isExpanded = useExpanded();
|
|
227
284
|
const telemetry = useTelemetry();
|
|
285
|
+
// Host-level trust for raw HTML in marker/popup content (audit P1.2).
|
|
286
|
+
// Default false → tooltip/popup escaped, GeoJSON `popup.template` ignored.
|
|
287
|
+
const allowHtml = () => props.allowHtmlPopups === true;
|
|
228
288
|
|
|
229
289
|
const params = () => props.params || (props.component?.params as MapComponentParams);
|
|
230
290
|
|
|
@@ -334,8 +394,7 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
334
394
|
});
|
|
335
395
|
p?.markers?.forEach((marker) => {
|
|
336
396
|
const m = L.marker(marker.position);
|
|
337
|
-
|
|
338
|
-
if (marker.popup) m.bindPopup(marker.popup);
|
|
397
|
+
bindMarkerContent(m, marker, allowHtml());
|
|
339
398
|
clusterGroup.addLayer(m);
|
|
340
399
|
markers.push(m);
|
|
341
400
|
});
|
|
@@ -343,16 +402,14 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
343
402
|
} catch {
|
|
344
403
|
p?.markers?.forEach((marker) => {
|
|
345
404
|
const m = L.marker(marker.position).addTo(mapInstance);
|
|
346
|
-
|
|
347
|
-
if (marker.popup) m.bindPopup(marker.popup);
|
|
405
|
+
bindMarkerContent(m, marker, allowHtml());
|
|
348
406
|
markers.push(m);
|
|
349
407
|
});
|
|
350
408
|
}
|
|
351
409
|
} else {
|
|
352
410
|
p?.markers?.forEach((marker) => {
|
|
353
411
|
const m = L.marker(marker.position).addTo(mapInstance);
|
|
354
|
-
|
|
355
|
-
if (marker.popup) m.bindPopup(marker.popup);
|
|
412
|
+
bindMarkerContent(m, marker, allowHtml());
|
|
356
413
|
markers.push(m);
|
|
357
414
|
});
|
|
358
415
|
}
|
|
@@ -396,6 +453,7 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
396
453
|
}
|
|
397
454
|
|
|
398
455
|
// ─── PMTiles (v3.1.0) ────────────────────────
|
|
456
|
+
setPmtilesError(null);
|
|
399
457
|
if (p?.pmtiles) {
|
|
400
458
|
try {
|
|
401
459
|
// @ts-ignore — optional peer dependency, may not be installed
|
|
@@ -439,7 +497,23 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
439
497
|
|
|
440
498
|
pmLayer.addTo(mapInstance);
|
|
441
499
|
} catch (e) {
|
|
442
|
-
|
|
500
|
+
// P1.3 — do NOT fail silently. Keep the base map, but surface a
|
|
501
|
+
// visible notice and report a renderer error via telemetry. The
|
|
502
|
+
// most common cause is the optional `protomaps-leaflet` peer not
|
|
503
|
+
// being installed.
|
|
504
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
505
|
+
const message =
|
|
506
|
+
'PMTiles layer unavailable — the optional "protomaps-leaflet" ' +
|
|
507
|
+
'peer dependency failed to load or render.';
|
|
508
|
+
console.warn('[MCP-UI] ' + message, e);
|
|
509
|
+
setPmtilesError(message);
|
|
510
|
+
telemetry?.dispatch({
|
|
511
|
+
type: 'render:error',
|
|
512
|
+
errorMessage: `${message} (${detail})`,
|
|
513
|
+
id: props.component?.id ?? '',
|
|
514
|
+
componentType: 'map',
|
|
515
|
+
ts: Date.now(),
|
|
516
|
+
});
|
|
443
517
|
}
|
|
444
518
|
}
|
|
445
519
|
|
|
@@ -501,6 +575,16 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
|
501
575
|
</div>
|
|
502
576
|
</Show>
|
|
503
577
|
<Show when={!error()}>
|
|
578
|
+
{/* P1.3 — visible notice when the PMTiles overlay fails but the
|
|
579
|
+
base map is still usable. */}
|
|
580
|
+
<Show when={pmtilesError()}>
|
|
581
|
+
<div
|
|
582
|
+
role="alert"
|
|
583
|
+
class="m-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-200"
|
|
584
|
+
>
|
|
585
|
+
{pmtilesError()} The base map is still shown.
|
|
586
|
+
</div>
|
|
587
|
+
</Show>
|
|
504
588
|
<div
|
|
505
589
|
ref={mapContainer}
|
|
506
590
|
style={
|