From 6b8ac181c8baae2386408ddb98737ec4f1578d32 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 2 Oct 2026 14:44:43 -0400 Subject: [PATCH] Show inline map attribution as text and keep map tiles on the tile proxy The chat's inline map card escapes a tool result's tile attribution before OpenLayers renders it, and draws a result as a map only when its tile template points at /api/azure-maps/tile with {z}, {x} and {y}. Maps from the Azure Maps action are unchanged. Version 0.261.049. --- application/single_app/config.py | 2 +- .../static/js/chat/chat-inline-maps.js | 16 ++- .../fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md | 62 ++++++++++ docs/explanation/release_notes.md | 9 ++ .../test_chat_inline_map_attribution_text.py | 111 ++++++++++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 docs/explanation/fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md create mode 100644 functional_tests/test_chat_inline_map_attribution_text.py diff --git a/application/single_app/config.py b/application/single_app/config.py index ff2502c99..11e7b300d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.048" +VERSION = "0.261.049" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/static/js/chat/chat-inline-maps.js b/application/single_app/static/js/chat/chat-inline-maps.js index 87ec16d79..a20683960 100644 --- a/application/single_app/static/js/chat/chat-inline-maps.js +++ b/application/single_app/static/js/chat/chat-inline-maps.js @@ -3,6 +3,7 @@ import { fetchAgentCitationArtifact } from "./chat-citations.js"; import { escapeHtml } from "./chat-utils.js"; const AZURE_MAPS_RENDER_TYPE = "azure_maps_openlayers"; +const AZURE_MAPS_TILE_PROXY_PATH = "/api/azure-maps/tile"; function toFiniteNumber(value) { const numericValue = Number(value); @@ -56,6 +57,14 @@ function getCitationResult(candidate) { return null; } +// Tiles are requested with the user's session, so they only ever come from SimpleChat's tile proxy. +function isTileProxyTemplate(template) { + const value = typeof template === "string" ? template.trim() : ""; + return value.startsWith(`${AZURE_MAPS_TILE_PROXY_PATH}?`) + && !/[\s"'<>\\]/.test(value) + && ["{z}", "{x}", "{y}"].every((placeholder) => value.includes(placeholder)); +} + function isAzureMapsVisualization(result) { return Boolean( result @@ -63,7 +72,7 @@ function isAzureMapsVisualization(result) { && result.render_type === AZURE_MAPS_RENDER_TYPE && result.map_payload && typeof result.map_payload === "object" - && result.map_payload.tile_url_template + && isTileProxyTemplate(result.map_payload.tile_url_template) ); } @@ -389,8 +398,9 @@ function initializeOpenLayersMap(mapElement, popupElement, payload) { const tileLayer = new olRef.layer.Tile({ source: new olRef.source.XYZ({ - url: payload.tile_url_template, - attributions: payload.tile_attribution || "", + url: payload.tile_url_template.trim(), + // OpenLayers renders attributions as HTML, and this text comes from a tool result. + attributions: escapeHtml(payload.tile_attribution || ""), crossOrigin: "anonymous", maxZoom: payload.view?.max_zoom || 15, }), diff --git a/docs/explanation/fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md b/docs/explanation/fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md new file mode 100644 index 000000000..c108ba3a8 --- /dev/null +++ b/docs/explanation/fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md @@ -0,0 +1,62 @@ +# Inline Map Attribution Text Fix + +Fixed/Implemented in version: **0.261.049** + +## Issue Description + +The chat's inline map card passed a tool result's `map_payload.tile_attribution` straight to +OpenLayers, which renders attribution strings as HTML. The card also drew any tool result shaped +like a map, whatever its `tile_url_template` named. A result from a tool other than the Azure +Maps action could therefore place markup in the map's attribution, or make the browser load map +tiles from another host. + +## Root Cause Analysis + +- `initializeOpenLayersMap` set `attributions: payload.tile_attribution`, and OpenLayers' + Attribution control writes each attribution string into the page as HTML. +- `isAzureMapsVisualization` only checked that `tile_url_template` was present, not that it + pointed at SimpleChat's tile proxy. +- The Azure Maps action itself always returns a fixed attribution and a proxied tile template, + so maps from that action were never affected. + +## Version Implemented + +- **0.261.049** +- Application version updated in `application/single_app/config.py` to `0.261.049`. + +## Files Modified + +- `application/single_app/static/js/chat/chat-inline-maps.js` +- `application/single_app/config.py` +- `functional_tests/test_chat_inline_map_attribution_text.py` +- `docs/explanation/release_notes.md` + +## Code Changes Summary + +- The attribution is passed through the existing `escapeHtml` helper before OpenLayers receives + it, so it is shown as text. +- New `isTileProxyTemplate` accepts only templates that start with `/api/azure-maps/tile?`, + contain `{z}`, `{x}` and `{y}`, and have no whitespace, quotes, angle brackets or backslashes. + `isAzureMapsVisualization` requires it, so a result naming any other tile source is not drawn. + +## Testing Approach + +- `functional_tests/test_chat_inline_map_attribution_text.py` checks that the attribution is + escaped and that the map check requires the proxy template. It then runs the real + `isTileProxyTemplate` function in Node against proxy, foreign, protocol-relative, incomplete and + quoted templates. +- `ui_tests/test_chat_inline_azure_maps_rendering.py` already uses a proxied template, so the + existing rendering coverage still applies. + +## Impact Analysis + +- Maps from the Azure Maps action render exactly as before; the escaped attribution reads the + same. +- A tool result that only resembles a map, with a tile template outside the proxy, is no longer + drawn as a map. Its citation is still available through the normal citation controls. + +## Validation + +- Before: attribution text from a tool result reached the page as HTML, and tiles could load from + any template. +- After: the attribution is text, and tiles only load through `/api/azure-maps/tile`. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index f092bc0aa..100f71891 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.049)** + +#### Bug Fixes + +* **Inline Map Attribution Shown as Text and Tiles Kept on the Tile Proxy** + * Inline maps in the chat now show a tool result's tile attribution as plain text. OpenLayers renders attribution strings as HTML, so the text is escaped first. + * A tool result is drawn as a map only when its tile template points at SimpleChat's Azure Maps tile proxy (`/api/azure-maps/tile`). Maps from the Azure Maps action are unchanged. + * (Ref: `chat-inline-maps.js`, [Inline Map Attribution Text Fix](fixes/INLINE_MAP_ATTRIBUTION_TEXT_FIX.md)) + ### **(v0.261.048)** #### Bug Fixes diff --git a/functional_tests/test_chat_inline_map_attribution_text.py b/functional_tests/test_chat_inline_map_attribution_text.py new file mode 100644 index 000000000..aabca0e3f --- /dev/null +++ b/functional_tests/test_chat_inline_map_attribution_text.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# test_chat_inline_map_attribution_text.py +""" +Functional test for the inline map attribution text fix. +Version: 0.261.049 +Implemented in: 0.261.049 + +This test ensures that the chat's inline map card shows a tool result's tile attribution as +text rather than HTML, and draws a tool result as a map only when its tile template points at +SimpleChat's Azure Maps tile proxy. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INLINE_MAPS_JS = ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-inline-maps.js" + +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +IMPLEMENTED_IN = "0.261.049" + + +def _source(): + return INLINE_MAPS_JS.read_text(encoding="utf-8") + + +def test_attribution_is_escaped_before_openlayers_renders_it(): + """OpenLayers writes attributions as HTML, so the tool result's text is escaped first.""" + print("Testing inline map attribution escaping...") + source = _source() + + assert 'import { escapeHtml } from "./chat-utils.js";' in source + assert 'attributions: escapeHtml(payload.tile_attribution || ""),' in source + assert "attributions: payload.tile_attribution" not in source + print("Attribution escaping passed!") + + +def test_maps_require_the_tile_proxy_template(): + """A tool result is drawn as a map only when its tiles come through the tile proxy.""" + print("Testing the tile template requirement...") + source = _source() + + assert 'const AZURE_MAPS_TILE_PROXY_PATH = "/api/azure-maps/tile";' in source + assert "&& isTileProxyTemplate(result.map_payload.tile_url_template)" in source + assert "&& result.map_payload.tile_url_template\n" not in source + print("Tile template requirement passed!") + + +def test_tile_proxy_template_check_behaves(): + """Run the real isTileProxyTemplate function in Node against accepted and refused templates.""" + print("Testing isTileProxyTemplate in Node...") + source = _source() + + match = re.search(r"function isTileProxyTemplate\(template\) \{.*?\n\}\n", source, re.S) + assert match, "isTileProxyTemplate was not found" + + proxy = ( + "/api/azure-maps/tile?token=abc&api-version=2024-04-01&tilesetId=microsoft.base.road" + "&zoom={z}&x={x}&y={y}&tileSize=256" + ) + cases = { + proxy: True, + f" {proxy} ": True, + "https://tiles.example.test/{z}/{x}/{y}.png": False, + "//tiles.example.test/api/azure-maps/tile?{z}{x}{y}": False, + "/api/azure-maps/tiles?{z}{x}{y}": False, + "/api/azure-maps/tile?zoom={z}&x={x}": False, + '/api/azure-maps/tile?token=">&{z}{x}{y}': False, + "": False, + } + script = ( + 'const AZURE_MAPS_TILE_PROXY_PATH = "/api/azure-maps/tile";\n' + + match.group(0) + + f"const cases = {json.dumps(list(cases.items()))};\n" + + "const wrong = cases.filter(([value, expected]) => isTileProxyTemplate(value) !== expected);\n" + + "for (const value of [undefined, null, 42, {}]) { if (isTileProxyTemplate(value)) wrong.push([String(value), false]); }\n" + + "if (wrong.length) { console.error(JSON.stringify(wrong)); process.exit(1); }\n" + + "console.log(`${cases.length + 4} template checks passed`);\n" + ) + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, f"isTileProxyTemplate returned the wrong answer for: {result.stderr}" + print(f" {result.stdout.strip()}") + print("isTileProxyTemplate passed!") + + +def test_version_was_incremented(): + assert_app_version_at_least(IMPLEMENTED_IN) + + +if __name__ == "__main__": + tests = [ + test_attribution_is_escaped_before_openlayers_renders_it, + test_maps_require_the_tile_proxy_template, + test_tile_proxy_template_check_behaves, + test_version_was_incremented, + ] + failures = 0 + for test in tests: + try: + test() + except Exception as exc: # noqa: BLE001 - report every failing check + failures += 1 + print(f"FAILED {test.__name__}: {exc}") + print(f"\n{len(tests) - failures}/{len(tests)} tests passed") + sys.exit(1 if failures else 0)