fix(agent): validate UI envelopes

This commit is contained in:
2026-07-14 11:01:07 +08:00
parent 1c85d938a6
commit ed60a13f12
6 changed files with 276 additions and 31 deletions
+70 -9
View File
@@ -1,6 +1,19 @@
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
import { toTrustedMapAction } from "./map-actions";
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
const MAX_ID_LENGTH = 128;
const LOCAL_COMPONENT_SURFACES = new Map<string, UISurface[]>([
["HistoryPanel", ["side_panel", "canvas"]],
["ScadaPanel", ["side_panel", "canvas"]]
]);
const LOCAL_ACTION_SURFACES = new Map<string, UISurface[]>([
["locate_features", ["map_overlay"]],
["zoom_to_map", ["map_overlay"]],
["render_junctions", ["map_overlay"]],
["apply_layer_style", ["map_overlay"]]
]);
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
if (!isRecord(value)) {
@@ -12,10 +25,16 @@ export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null
return null;
}
const sessionId = boundedString(value.session_id, MAX_ID_LENGTH);
const envelopeId = boundedString(value.envelope_id, MAX_ID_LENGTH);
if (!sessionId || !envelopeId || !isFiniteTimestamp(value.created_at)) {
return null;
}
return {
session_id: typeof value.session_id === "string" ? value.session_id : "",
envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "",
created_at: typeof value.created_at === "number" ? value.created_at : Date.now(),
session_id: sessionId,
envelope_id: envelopeId,
created_at: value.created_at,
envelope
};
}
@@ -26,7 +45,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
}
if (value.kind === "registered_component") {
if (typeof value.component !== "string" || !isSurface(value.surface)) {
if (typeof value.component !== "string" || !isSurface(value.surface) || !isValidComponentProps(value.component, value.props)) {
return null;
}
@@ -42,7 +61,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
}
if (value.kind === "chart") {
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface)) {
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface) || !isValidChart(value.spec, value.data)) {
return null;
}
@@ -58,7 +77,7 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
}
if (value.kind === "map_action") {
if (typeof value.action !== "string" || value.surface !== "map_overlay") {
if (typeof value.action !== "string" || value.surface !== "map_overlay" || !toTrustedMapAction(value.action, value.params)) {
return null;
}
@@ -77,18 +96,20 @@ export function parseUiEnvelope(value: unknown): UIEnvelope | null {
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
if (!registry) {
return true;
return false;
}
if (envelope.kind === "registered_component") {
if (!LOCAL_COMPONENT_SURFACES.get(envelope.component)?.includes(envelope.surface)) return false;
const manifest = registry.components.find((item) => item.id === envelope.component);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
if (envelope.kind === "chart") {
return registry.chart_grammars.includes(envelope.grammar);
return envelope.grammar === "echarts-safe-subset" && registry.chart_grammars.includes(envelope.grammar);
}
if (!LOCAL_ACTION_SURFACES.get(envelope.action)?.includes(envelope.surface)) return false;
const manifest = registry.actions.find((item) => item.id === envelope.action);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
@@ -137,5 +158,45 @@ function optionalString(value: unknown) {
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function boundedString(value: unknown, maxLength: number) {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength ? value.trim() : null;
}
function isFiniteTimestamp(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]) {
const keys = new Set(allowed);
return Object.keys(value).every((key) => keys.has(key));
}
function isValidComponentProps(component: string, props: unknown) {
if (!isRecord(props)) return false;
if (component === "HistoryPanel") {
if (!hasOnlyKeys(props, ["reason", "feature_infos", "data_type", "start_time", "end_time"])) return false;
const items = props.feature_infos;
return Array.isArray(items) && items.length > 0 && items.length <= 100 &&
items.every((item) => Array.isArray(item) && item.length === 2 && item.every((part) => boundedString(part, 128))) &&
(props.data_type === "realtime" || props.data_type === "scheme" || props.data_type === "none");
}
if (component === "ScadaPanel") {
if (!hasOnlyKeys(props, ["reason", "device_ids", "device_id", "start_time", "end_time"])) return false;
const ids = Array.isArray(props.device_ids) ? props.device_ids : props.device_id ? [props.device_id] : [];
return ids.length > 0 && ids.length <= 100 && ids.every((id) => Boolean(boundedString(id, 128)));
}
return false;
}
function isValidChart(spec: unknown, data: unknown) {
if (!isRecord(spec) || !hasOnlyKeys(spec, ["title", "chart_type", "x_axis_name", "y_axis_name"])) return false;
if (spec.chart_type !== undefined && spec.chart_type !== "line" && spec.chart_type !== "bar") return false;
const parsedSpec = parseChartSpec(spec);
const parsedData = normalizeSafeChartData(data, 100);
return (parsedSpec.chartType === "line" || parsedSpec.chartType === "bar") && parsedData !== null &&
parsedData.xData.length <= 100 && parsedData.series.length <= 8 &&
parsedData.series.every((series) => series.data.length === parsedData.xData.length);
}