AID-H-028.004: Client Response Parsing, Rendering & Execution Surface Hardening

Version 1.20261015 · Actionable sub-technique

Prevent malicious or compromised MCP servers from exploiting the client through unsafe response parsing, rendering, or implicit execution behavior. Attack vectors include malformed or oversized protocol messages, malicious markup or terminal escape sequences, unsafe markdown or HTML rendering, parser edge-case crashes, and server-suggested actions that the client auto-executes without clear confirmation. This sub-technique enforces strict schema validation, size and depth limits, content sanitization, renderer isolation, and a no-implicit-execution default for server-suggested actions.

Pillars: app

Lifecycle: building, operation

Scope boundary

Owns client-side schema and size validation, content sanitization, renderer isolation, terminal-escape handling, and denial of implicit execution for server responses. It does not make server-returned content semantically trustworthy.

AID-H-034.005: AID-H-028.004 safely parses and renders content after it reaches the client; AID-H-034.005 constrains and labels tool outputs, resources, prompts, and input requests before they leave the server. Server output controls do not replace a hostile-input-safe client renderer.

Implementation guidance

AID-H-028.004-G001

Validate every incoming MCP message against a strict schema and enforce maximum payload size, nesting depth, and read timeout before parsing or rendering.
Concept:

Reject malformed or oversized messages as early as possible. This reduces parser edge cases, memory pressure, and unsafe fallback behavior.

Example Node.js schema validation with Ajv
// File: src/mcp/validate-incoming-message.mjs
import Ajv from "ajv";

const ajv = new Ajv({allErrors: true, removeAdditional: false});
function compileMessageValidator(maxTypeCharacters) {
  if (!Number.isSafeInteger(maxTypeCharacters) || maxTypeCharacters <= 0) {
    throw new Error("verified MCP type-length bound is invalid");
  }
  return ajv.compile({
    type: "object",
    required: ["type", "payload"],
    additionalProperties: false,
    properties: {
      type: { type: "string", maxLength: maxTypeCharacters },
      payload: { type: "object" }
    }
  });
}

export function validateIncomingMessage(raw, policy) {
  const { maxBytes, maxTypeCharacters } = policy || {};
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
    throw new Error("verified MCP message-size bound is invalid");
  }
  const validate = compileMessageValidator(maxTypeCharacters);
  if (Buffer.byteLength(raw, "utf8") > maxBytes) {
    throw new Error("MCP message too large");
  }
  const parsed = JSON.parse(raw);
  if (!validate(parsed)) {
    throw new Error(`Schema validation failed: ${ajv.errorsText(validate.errors)}`);
  }
  return parsed;
}

Operational notes: Apply separate limits for binary attachments, structured payloads, and recursive nested objects. Log validation failures with message type and size, not full message contents.

Production implementation

At the framed reader/parser, enforce policy-derived byte, depth, item and read-time bounds, validate the pinned MCP schema with unknown-field denial, reject duplicate/ambiguous encodings and release only an immutable validated object.

Independent verification

A separately credentialed verifier independently reads back or replays wire bytes, parser counters, schema result and downstream object hash with oversize, deep, slow, duplicate-key, truncation and schema-drift fixtures.

AID-H-028.004-G002

Sanitize all rendered response content and harden the renderer so server-supplied markup, scripts, or terminal control sequences cannot execute or mislead the user.
Concept:

If the client renders markdown, HTML, or rich content, sanitize before render and keep the renderer in a restricted environment. For terminal clients, strip ANSI escape sequences that can rewrite or hide output.

Example DOMPurify usage in an Electron renderer
// File: src/mcp/render-safe-html.mjs
import DOMPurify from "dompurify";

export function renderSafeHtml(untrustedHtml) {
  const clean = DOMPurify.sanitize(untrustedHtml, {
    ALLOWED_TAGS: ["b", "i", "strong", "em", "p", "ul", "li", "code", "pre", "a"],
    ALLOWED_ATTR: ["href", "title"],
    FORBID_TAGS: ["script", "iframe", "object"],
    FORBID_ATTR: ["onerror", "onclick", "onload"],
  });
  document.getElementById("content").innerHTML = clean;
}

Operational notes: In Electron, keep nodeIntegration disabled, enable contextIsolation, and apply a restrictive Content Security Policy. For terminal clients, strip or neutralize ANSI escape codes before display.

Production implementation

At the trusted renderer boundary, sanitize against the signed allowlist, neutralize active URLs and terminal controls, render in a non-privileged surface with no script or host bridge, and refuse content when sanitizer or policy readback fails.

Independent verification

A separately credentialed verifier independently reads back or replays sanitized bytes and effective DOM/terminal output with script, event-handler, CSS URL, bidi, ANSI, markdown-link and sanitizer-failure fixtures.

AID-H-028.004-G003

Require explicit user confirmation before executing any server-suggested action, and display the action, target, and parameters in a normalized read-only confirmation view.
Concept:

The client should never silently execute a server-suggested action. Intercept suggested actions, convert them to a structured confirmation object, and show the user exactly what will happen.

Example confirmation object
{
  "server": "finance-mcp",
  "action": "create_ticket",
  "target": "jira://SEC-1234",
  "parameters": {
    "priority": "high",
    "summary": "Rotate exposed API key"
  },
  "risk_tier": "high"
}

Operational notes: Do not allow hidden parameters, free-form HTML in confirmations, or auto-approve for newly discovered servers. If you later add allowlists, keep them narrow and auditable.

Production implementation

At the client-controlled confirmation broker, canonicalize the exact action, target and parameters, display their immutable digest and policy risk, obtain fresh policy-required approval, then rehash before forwarding; cancellation, mutation or missing approval denies execution.

Independent verification

A separately credentialed verifier independently reads back or replays proposal, normalized view, approval receipt and final dispatched bytes with mutation, hidden parameter, stale approval, cancellation and dispatcher-failure fixtures.

AID-H-028.004-G004

Require explicit user confirmation before opening server-suggested URLs, and display the full normalized destination without prefetching or preauthenticated links.
Concept:

URL-mode elicitation is a special case of server-suggested action because navigation can leak tokens, open a phishing destination, trigger a magic link, or cause a browser-agent to perform a side effect. The client must not prefetch, auto-open, shorten, style-hide, or silently rewrite the destination. Show the full normalized URL in a read-only confirmation view, block preauthenticated URLs by default, and record the user's explicit consent before navigation. Browser/session isolation after consent belongs to AID-I-008.003.

Authoritative one-time consent ledger
CREATE TABLE url_navigation_decisions (
  decision_id TEXT PRIMARY KEY,
  consent_id TEXT UNIQUE,
  receipt_sha256 CHAR(64) UNIQUE,
  server_id TEXT NOT NULL,
  normalized_url TEXT NOT NULL,
  user_id TEXT NOT NULL,
  session_id TEXT NOT NULL,
  verdict TEXT NOT NULL CHECK (verdict IN ('allow', 'deny')),
  reason TEXT NOT NULL,
  receipt_expires_at_epoch BIGINT,
  decided_at_epoch BIGINT NOT NULL,
  CHECK (
    (verdict = 'allow' AND consent_id IS NOT NULL AND receipt_sha256 IS NOT NULL AND receipt_expires_at_epoch IS NOT NULL)
    OR verdict = 'deny'
  )
);
REVOKE ALL ON url_navigation_decisions FROM PUBLIC;
GRANT INSERT, SELECT ON url_navigation_decisions TO url_consent_broker;
URL confirmation gate
# File: mcp_client/url_elicitation_gate.py
from __future__ import annotations
import hashlib, ipaddress, json, math, os, subprocess, tempfile, time, uuid
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import parse_qsl, urlsplit, urlunsplit
import jwt
import psycopg

POLICY_KEY = Path("/opt/aidefend/trust/url-consent-policy.pub")
SENSITIVE_QUERY_KEYS = {"token", "code", "session", "auth", "signature", "sig", "key", "jwt", "sso"}

@dataclass(frozen=True)
class BrokerPolicy:
    policy_version: str; issuer: str; audience: str; key_id: str; public_key_pem: str
    allowed_origins: frozenset[str]; allowed_ports: frozenset[int]
    max_receipt_lifetime_seconds: int; clock_skew_seconds: int

@dataclass(frozen=True)
class UrlConsentDecision:
    decision_id: str; server_id: str; normalized_url: str; user_id: str; session_id: str
    verdict: str; reason: str; consent_id: str | None; receipt_sha256: str | None
    receipt_expires_at_epoch: int | None; decided_at_epoch: int

def verify_timeout() -> float:
    value = float(os.environ["VERIFIED_H028_URL_POLICY_VERIFY_TIMEOUT_SECONDS"])
    if not math.isfinite(value) or value <= 0: raise ValueError("policy verification timeout must be finite and positive")
    return value

def load_policy(path: str, signature: str) -> BrokerPolicy:
    payload = Path(path).read_bytes()
    with tempfile.NamedTemporaryFile(prefix="aidefend-url-policy-", delete=False) as handle:
        handle.write(payload); snapshot = Path(handle.name)
    try:
        subprocess.run(["cosign", "verify-blob", "--key", str(POLICY_KEY), "--bundle", signature, str(snapshot)], check=True, capture_output=True, text=True, timeout=verify_timeout())
    finally:
        snapshot.unlink(missing_ok=True)
    raw = json.loads(payload)
    fields = {"schema_version", "policy_version", "issuer", "audience", "key_id", "public_key_path", "public_key_sha256", "allowed_origins", "allowed_ports", "max_receipt_lifetime_seconds", "clock_skew_seconds"}
    if not isinstance(raw, dict) or set(raw) != fields or raw["schema_version"] != "aidefend.url-consent-policy.v1": raise ValueError("URL consent policy schema mismatch")
    for field in ("policy_version", "issuer", "audience", "key_id", "public_key_path"):
        if not isinstance(raw[field], str) or not raw[field]: raise ValueError(field + " is required")
    origins = raw["allowed_origins"]
    if not isinstance(origins, list) or not origins or len(origins) != len(set(origins)) or any(not isinstance(origin, str) or not origin.startswith("https://") or any(char in origin[len("https://"):] for char in "/?#") for origin in origins): raise ValueError("allowed origin population is invalid")
    ports = raw["allowed_ports"]
    if not isinstance(ports, list) or not ports or len(ports) != len(set(ports)) or any(isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535 for port in ports): raise ValueError("allowed port population is invalid")
    maximum, skew = raw["max_receipt_lifetime_seconds"], raw["clock_skew_seconds"]
    if isinstance(maximum, bool) or not isinstance(maximum, int) or maximum < 1: raise ValueError("maximum consent lifetime must be positive")
    if isinstance(skew, bool) or not isinstance(skew, int) or skew < 0: raise ValueError("clock skew must be non-negative")
    key_path = Path(raw["public_key_path"])
    if not key_path.is_absolute() or key_path.is_symlink() or not key_path.is_file(): raise ValueError("receipt verification key must be one absolute non-link file")
    key_bytes = key_path.read_bytes(); key_digest = raw["public_key_sha256"]
    if not isinstance(key_digest, str) or len(key_digest) != 64 or any(char not in "0123456789abcdef" for char in key_digest) or hashlib.sha256(key_bytes).hexdigest() != key_digest: raise ValueError("receipt verification key digest differs")
    return BrokerPolicy(raw["policy_version"], raw["issuer"], raw["audience"], raw["key_id"], key_bytes.decode("utf-8"), frozenset(origins), frozenset(ports), maximum, skew)

def canonical_host(hostname: str) -> str:
    try:
        address = ipaddress.ip_address(hostname)
        return f"[{address.compressed}]" if address.version == 6 else address.compressed
    except ValueError:
        value = hostname.encode("idna").decode("ascii").lower().rstrip(".")
        if not value: raise ValueError("missing_hostname")
        return value

def normalize_for_display(raw_url: str, policy: BrokerPolicy) -> str:
    if not isinstance(raw_url, str) or raw_url != raw_url.strip() or any(ord(char) < 0x20 for char in raw_url) or "\\" in raw_url or "#" in raw_url: raise ValueError("ambiguous_navigation_url")
    parts = urlsplit(raw_url)
    if parts.scheme.lower() != "https" or not parts.hostname: raise ValueError("https_destination_required")
    if parts.username is not None or parts.password is not None: raise ValueError("userinfo_not_allowed")
    host = canonical_host(parts.hostname)
    try: port = parts.port or 443
    except ValueError as error: raise ValueError("invalid_port") from error
    if port not in policy.allowed_ports: raise ValueError("port_not_allowed")
    origin = "https://" + host + ("" if port == 443 else f":{port}")
    if origin not in policy.allowed_origins: raise ValueError("origin_not_allowed")
    return urlunsplit(("https", origin[len("https://"):], parts.path or "/", parts.query, ""))

def contains_preauth_material(normalized_url: str) -> bool:
    keys = {key.casefold() for key, _ in parse_qsl(urlsplit(normalized_url).query, keep_blank_values=True)}
    return bool(keys & SENSITIVE_QUERY_KEYS)

def persist_and_readback(decision: UrlConsentDecision, write_dsn: str, read_dsn: str) -> None:
    values = (decision.decision_id, decision.consent_id, decision.receipt_sha256, decision.server_id, decision.normalized_url, decision.user_id, decision.session_id, decision.verdict, decision.reason, decision.receipt_expires_at_epoch, decision.decided_at_epoch)
    with psycopg.connect(write_dsn) as connection:
        with connection.cursor() as cursor:
            cursor.execute("""INSERT INTO url_navigation_decisions (decision_id, consent_id, receipt_sha256, server_id, normalized_url, user_id, session_id, verdict, reason, receipt_expires_at_epoch, decided_at_epoch) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING RETURNING decision_id""", values)
            inserted = cursor.fetchone()
        connection.commit()
    if inserted is None: raise PermissionError("consent receipt replayed or decision identity duplicated")
    with psycopg.connect(read_dsn) as connection:
        with connection.cursor() as cursor:
            cursor.execute("""SELECT decision_id, consent_id, receipt_sha256, server_id, normalized_url, user_id, session_id, verdict, reason, receipt_expires_at_epoch, decided_at_epoch FROM url_navigation_decisions WHERE decision_id=%s""", (decision.decision_id,))
            readback = cursor.fetchone()
    if readback != values: raise RuntimeError("authoritative consent-ledger readback differs")

def deny(server_id: str, normalized: str, user_id: str, session_id: str, reason: str, write_dsn: str, read_dsn: str) -> UrlConsentDecision:
    decision = UrlConsentDecision(str(uuid.uuid4()), server_id, normalized, user_id, session_id, "deny", reason, None, None, None, int(time.time()))
    persist_and_readback(decision, write_dsn, read_dsn); return decision

def require_user_url_consent(server_id: str, raw_url: str, authenticated_user_id: str, authenticated_session_id: str, receipt_token: str | None, policy: BrokerPolicy, write_dsn: str, read_dsn: str) -> UrlConsentDecision:
    if any(not isinstance(value, str) or not value for value in (server_id, authenticated_user_id, authenticated_session_id)): raise ValueError("authenticated broker context is incomplete")
    normalized = normalize_for_display(raw_url, policy)
    if contains_preauth_material(normalized): return deny(server_id, normalized, authenticated_user_id, authenticated_session_id, "preauthenticated_url_blocked", write_dsn, read_dsn)
    if not isinstance(receipt_token, str) or not receipt_token: return deny(server_id, normalized, authenticated_user_id, authenticated_session_id, "signed_consent_missing", write_dsn, read_dsn)
    header = jwt.get_unverified_header(receipt_token)
    if not isinstance(header, dict) or set(header) != {"alg", "kid", "typ"} or header != {"alg": "ES256", "kid": policy.key_id, "typ": "JWT"}: raise PermissionError("consent receipt header denied")
    claims = jwt.decode(receipt_token, policy.public_key_pem, algorithms=["ES256"], audience=policy.audience, issuer=policy.issuer, leeway=policy.clock_skew_seconds, options={"require": ["iss", "aud", "sub", "session_id", "server_id", "normalized_url", "jti", "iat", "nbf", "exp"]})
    required = {"iss", "aud", "sub", "session_id", "server_id", "normalized_url", "jti", "iat", "nbf", "exp"}
    if not isinstance(claims, dict) or set(claims) != required: raise PermissionError("consent receipt claim population differs")
    expected = {"sub": authenticated_user_id, "session_id": authenticated_session_id, "server_id": server_id, "normalized_url": normalized}
    if any(claims[field] != value for field, value in expected.items()): raise PermissionError("consent receipt binding differs")
    if not isinstance(claims["jti"], str) or not claims["jti"]: raise PermissionError("consent ID missing")
    if any(isinstance(claims[field], bool) or not isinstance(claims[field], int) for field in ("iat", "nbf", "exp")): raise PermissionError("consent receipt time claims must be integer epochs")
    now = int(time.time())
    if not claims["iat"] <= claims["nbf"] < claims["exp"] or claims["iat"] > now + policy.clock_skew_seconds or claims["exp"] - claims["iat"] > policy.max_receipt_lifetime_seconds: raise PermissionError("consent receipt lifetime denied")
    receipt_sha256 = hashlib.sha256(receipt_token.encode()).hexdigest()
    decision = UrlConsentDecision(str(uuid.uuid4()), server_id, normalized, authenticated_user_id, authenticated_session_id, "allow", "signed_one_time_user_consent", claims["jti"], receipt_sha256, claims["exp"], now)
    persist_and_readback(decision, write_dsn, read_dsn); return decision
Verification test
# File: tests/test_url_elicitation_gate.py
import os, time, uuid
import jwt, pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from mcp_client.url_elicitation_gate import BrokerPolicy, require_user_url_consent

TEST_DSN = os.environ["TEST_URL_CONSENT_LEDGER_DSN"]

def fixture_policy(tmp_path):
    private = ec.generate_private_key(ec.SECP256R1())
    public_path = tmp_path / "consent.pub"
    public_path.write_bytes(private.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo))
    policy = BrokerPolicy("test-v1", "https://consent.test", "url-broker-test", "test-key", public_path.read_text(encoding="utf-8"), frozenset({"https://example.com"}), frozenset({443}), 300, 2)
    return private, policy

def issue(private, policy, *, server="mcp-server-a", user="user-7", session="session-9", url="https://example.com/docs?a=1&b=2", jti=None):
    now = int(time.time())
    claims = {"iss": policy.issuer, "aud": policy.audience, "sub": user, "session_id": session, "server_id": server, "normalized_url": url, "jti": jti or str(uuid.uuid4()), "iat": now, "nbf": now, "exp": now + 60}
    key = private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
    return jwt.encode(claims, key, algorithm="ES256", headers={"kid": policy.key_id, "typ": "JWT"})

def test_blocks_preauthenticated_url_and_reads_back(tmp_path):
    _, policy = fixture_policy(tmp_path)
    decision = require_user_url_consent("mcp-server-a", "https://example.com/login?token=abc", "user-7", "session-9", None, policy, TEST_DSN, TEST_DSN)
    assert decision.verdict == "deny" and decision.reason == "preauthenticated_url_blocked"

def test_signed_receipt_is_bound_and_single_use(tmp_path):
    private, policy = fixture_policy(tmp_path)
    token = issue(private, policy)
    first = require_user_url_consent("mcp-server-a", "HTTPS://Example.com/docs?a=1&b=2", "user-7", "session-9", token, policy, TEST_DSN, TEST_DSN)
    assert first.verdict == "allow" and first.normalized_url == "https://example.com/docs?a=1&b=2"
    with pytest.raises(PermissionError, match="replayed"):
        require_user_url_consent("mcp-server-a", "https://example.com/docs?a=1&b=2", "user-7", "session-9", token, policy, TEST_DSN, TEST_DSN)

def test_receipt_cannot_cross_session_or_destination(tmp_path):
    private, policy = fixture_policy(tmp_path); token = issue(private, policy)
    with pytest.raises(PermissionError, match="binding differs"):
        require_user_url_consent("mcp-server-a", "https://example.com/other", "user-7", "session-10", token, policy, TEST_DSN, TEST_DSN)

Action: Route every server-suggested URL, link-opening request, and navigation elicitation through the trusted broker before prefetch or browser launch. Derive user and session identities from authenticated middleware, require the signed one-time receipt for an allow, atomically consume it in the authoritative ledger, and require exact readback before launch. Retain the policy/receipt digests and database decision record; do not treat a caller boolean or local JSONL file as consent evidence.

Production implementation

At the trusted URL broker, parse and display the full canonical HTTPS destination, reject userinfo, fragments, disallowed ports/schemes and unapproved origins, prohibit prefetch and credential attachment, obtain fresh confirmation, then revalidate immediately before launch.

Independent verification

A separately credentialed verifier independently reads back or replays displayed and effective destination, DNS/redirect result and launch receipt with homograph, userinfo, redirect, DNS-change, alternate-port and cancel fixtures.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-028.004: Client Response Parsing, Rendering & Execution Surface Hardening | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version