From 6bd620dd08dd5d6038288df70ef572550bcd1090 Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Tue, 3 Mar 2026 23:08:18 +0000 Subject: [PATCH] Escape dashboard HTML payload rendering --- elixir/lib/symphony_elixir/http_server.ex | 12 ++++- .../test/symphony_elixir/extensions_test.exs | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/elixir/lib/symphony_elixir/http_server.ex b/elixir/lib/symphony_elixir/http_server.ex index 8e1f8625..585132ae 100644 --- a/elixir/lib/symphony_elixir/http_server.ex +++ b/elixir/lib/symphony_elixir/http_server.ex @@ -411,6 +411,7 @@ defmodule SymphonyElixir.HttpServer do payload = state_payload(orchestrator, snapshot_timeout_ms) title = "Symphony Dashboard" body = Jason.encode!(payload, pretty: true) + escaped_body = escape_html(body) """ @@ -426,12 +427,21 @@ defmodule SymphonyElixir.HttpServer do

#{title}

-
#{body}
+
#{escaped_body}
""" end + defp escape_html(value) when is_binary(value) do + value + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + |> String.replace("'", "'") + end + defp parse_host({_, _, _, _} = ip), do: {:ok, ip} defp parse_host({_, _, _, _, _, _, _, _} = ip), do: {:ok, ip} diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 2c40990e..08efc891 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -375,6 +375,54 @@ defmodule SymphonyElixir.ExtensionsTest do assert %{"coalesced" => false, "operations" => ["poll", "reconcile"], "queued" => true} = Jason.decode!(body) end + test "http server escapes html-sensitive characters in rendered dashboard payload" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + orchestrator_name = Module.concat(__MODULE__, :EscapingHttpOrchestrator) + server_name = Module.concat(__MODULE__, :EscapingHttpServer) + {:ok, orchestrator_pid} = Orchestrator.start_link(name: orchestrator_name) + + {:ok, server_pid} = + HttpServer.start_link( + name: server_name, + host: "127.0.0.1", + port: 0, + orchestrator: orchestrator_name, + snapshot_timeout_ms: 1_000 + ) + + on_exit(fn -> + if Process.alive?(server_pid), do: Process.exit(server_pid, :normal) + if Process.alive?(orchestrator_pid), do: Process.exit(orchestrator_pid, :normal) + end) + + running_entry = %{ + pid: self(), + ref: make_ref(), + identifier: "MT-897", + issue: %Issue{id: "issue-html", identifier: "MT-897", state: "In Progress"}, + session_id: "thread-html", + turn_count: 7, + codex_app_server_pid: nil, + last_codex_message: "", + last_codex_timestamp: nil, + last_codex_event: :notification, + codex_input_tokens: 4, + codex_output_tokens: 8, + codex_total_tokens: 12, + started_at: DateTime.utc_now() + } + + :sys.replace_state(orchestrator_pid, fn state -> + %{state | running: %{"issue-html" => running_entry}, retry_attempts: %{}} + end) + + port = wait_for_bound_port(server_name) + {status, _headers, body} = http_request(port, "GET", "/") + assert status == 200 + refute String.contains?(body, "") + assert body =~ "<script>window.xssed=1</script>" + end + test "http server returns method, parse, timeout, and unavailable errors" do write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") server_name = Module.concat(__MODULE__, :ErrorHttpServer)