Uploads seguros de arquivos com cURL e certificados de cliente
Para fazer upload de um arquivo para um endpoint HTTPS que exige certificado de cliente, combine --upload-file,
--cert e --key no mesmo comando cURL. Este passo a passo oferece um servidor local de TLS mútuo (mTLS),
certificados temporários e um upload que você pode verificar byte a byte.
Separe a confiança no servidor da autenticação do cliente
O HTTPS criptografa a conexão e permite que o cURL verifique a identidade do servidor. Com mTLS, o servidor também exige a prova de que o cliente possui a chave privada de um certificado confiável. Essas verificações apontam em direções opostas:
| Opção do cURL | Finalidade neste exemplo |
|---|---|
--cacert ca.crt | Confiar na CA que emitiu o certificado do servidor e, ainda assim, verificar o hostname da URL. |
--cert client.crt | Apresentar o certificado público do cliente ao servidor. |
--key client.key | Provar a posse da chave privada correspondente. |
Um certificado de cliente não torna confiável um servidor não confiável. Mantenha a
verificação de servidor do cURL ativada; --insecure a ignoraria.
A CA temporária abaixo é considerada confiável apenas por estes comandos e por este receptor, sem
alterar o repositório de certificados confiáveis do seu sistema.
Prepare as ferramentas locais
Pré-requisitos
Use um shell Linux com Bash, cURL compilado com OpenSSL, OpenSSL 3, Python 3 e cmp. Nenhum
pacote Python é necessário. Verifique as ferramentas instaladas:
curl --version
openssl version
python3 --version
O comando cURL usa --fail-with-body, disponível desde o cURL 7.76.0. Veja as
combinações testadas abaixo; este passo a passo não estabelece o comportamento
para repositórios de certificados do Windows ou outros backends TLS.
Crie certificados temporários
Execute isto a partir de um diretório onde você possa criar mtls-demo. O subshell para em caso de
erros e se recusa a reutilizar um diretório existente. Seu terminal permanece no diretório pai. Todas
as chaves e certificados ficam dentro de mtls-demo, e os certificados expiram após dois dias.
(
set -eu
umask 077
mkdir mtls-demo
cd mtls-demo
openssl req -x509 -newkey rsa:2048 -noenc -sha256 -days 2 \
-keyout ca.key -out ca.crt -subj '/CN=Local upload demo CA' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl req -new -newkey rsa:2048 -noenc \
-keyout server.key -out server.csr -subj '/CN=localhost'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature,keyEncipherment' \
'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:localhost' > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-set_serial 1 -days 2 -sha256 -extfile server.ext -out server.crt
openssl req -new -newkey rsa:2048 -noenc \
-keyout client.key -out client.csr -subj '/CN=Local upload client'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature' 'extendedKeyUsage=clientAuth' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-set_serial 2 -days 2 -sha256 -extfile client.ext -out client.crt
)
As extensões de certificado atribuem papéis distintos ao servidor e ao cliente. O nome alternativo
do sujeito (subject alternative name) do servidor é localhost, que deve corresponder ao hostname
na URL de upload. O OpenSSL documenta essas
extensões de certificado e a
opção -noenc, que deixa essas chaves descartáveis
sem criptografia. umask 077 restringe o novo diretório e os arquivos à sua conta.
Inicie um receptor que exige certificado de cliente
Salve o conteúdo a seguir como mtls-demo/receiver.py. Ele aceita um PUT /upload bruto com um
Content-Length conhecido, incluindo corpo vazio, de até 1 MiB. Cada upload concluído substitui
received.bin; requisições rejeitadas deixam o arquivo anterior intacto.
import argparse
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
class UploadHandler(BaseHTTPRequestHandler):
def do_PUT(self):
if self.path != "/upload":
self.send_error(404, "Use /upload")
return
length = self.headers.get("Content-Length", "")
if self.headers.get("Transfer-Encoding") or not length.isascii() or not length.isdecimal():
self.send_error(411, "A Content-Length is required")
return
size = int(length)
if size > 1024 * 1024:
self.send_error(413, "Limit is 1 MiB")
return
self.connection.settimeout(10)
data = self.rfile.read(size)
if len(data) != size:
self.send_error(400, "Incomplete upload")
return
Path("received.bin").write_bytes(data)
reply = f"Stored {len(data)} bytes\n".encode()
self.send_response(201)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(reply)))
self.end_headers()
self.wfile.write(reply)
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=0)
args = parser.parse_args()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain("server.crt", "server.key")
context.load_verify_locations("ca.crt")
context.verify_mode = ssl.CERT_REQUIRED
with HTTPServer(("127.0.0.1", args.port), UploadHandler) as server:
server.socket = context.wrap_socket(server.socket, server_side=True)
print(f"https://localhost:{server.server_port}/upload", flush=True)
server.serve_forever()
O CERT_REQUIRED do Python rejeita
clientes sem um certificado válido emitido por uma CA confiável. Nesta demonstração, qualquer
certificado de cliente válido da nossa CA pode fazer upload. Um serviço real também precisa de uma
política de autorização própria.
Inicie o receptor a partir do diretório pai e deixe-o em execução:
(cd mtls-demo && python3 receiver.py)
Ele se vincula apenas ao loopback IPv4 e imprime uma URL de upload com uma porta disponível. Copie
essa URL para a próxima etapa. Um certificado ausente ou um erro de vinculação (bind) interrompe a
inicialização antes que qualquer URL seja impressa. Este é um servidor local para aprendizado; o
http.server do Python não é destinado a produção.
Faça upload do arquivo e compare
Em um segundo terminal, abra o mesmo diretório pai e crie um arquivo pequeno. Este comando substitui
qualquer payload.txt existente no diretório da demonstração:
(cd mtls-demo && printf 'mTLS upload\n' > payload.txt)
No bloco abaixo, substitua PORT pela porta impressa pelo receptor. Execute-o a partir do
diretório pai:
(
set -eu
cd mtls-demo
upload_url='https://localhost:PORT/upload'
status=$(curl --disable --silent --show-error --fail-with-body \
--noproxy '*' --connect-timeout 5 --max-time 20 \
--cacert ca.crt --cert client.crt --key client.key \
--upload-file payload.txt --output response.txt --write-out '%{http_code}' \
"$upload_url")
cat response.txt
printf 'HTTP %s\n' "$status"
test "$status" = 201
cmp payload.txt received.bin
)
Saída esperada:
Stored 12 bytes
HTTP 201
cmp não imprime nada quando os bytes de origem e os recebidos coincidem. Para este endpoint,
HTTP 201 significa que o receptor gravou o arquivo; isso não promete varredura, backups
nem armazenamento durável. O arquivo permanece no disco quando você encerra o receptor.
--upload-file seleciona HTTP PUT com o arquivo como corpo
da requisição. Uma API que espera um POST multipart precisa de outro formato de requisição; confirme
o método e o contrato de corpo do endpoint antes de adaptar este comando.
--fail-with-body faz com que erros HTTP retornem
o código de saída 22 do cURL, com o corpo da resposta salvo em response.txt. O subshell
para nessa falha. A verificação explícita de 201 também rejeita status inesperados de
sucesso ou de redirecionamento. Uma resposta recebida sobrescreve response.txt; uma falha precoce de
TLS pode deixar um arquivo de resposta antigo, então não use apenas esse arquivo como prova de
sucesso. --disable ignora sua configuração padrão do cURL, e
--noproxy '*' mantém esta requisição de loopback fora de proxies configurados no ambiente.
Solução de problemas comuns
Leia o erro do cURL antes de inspecionar qualquer resposta salva. Para ver o status de saída do
subshell, execute echo "$?" logo após o bloco de upload. Use estas verificações uma de cada vez,
restaurando o comando bem-sucedido entre as tentativas:
Falha na verificação do certificado
Trocar o hostname da URL de localhost para 127.0.0.1 deve produzir o erro 60 do
cURL: o certificado nomeia localhost, não o endereço IP. Uma CA não relacionada fornecida com
--cacert também falha na verificação. Use a CA confiável do operador do servidor e o hostname
coberto pelo certificado; não resolva nenhuma das falhas desativando a verificação.
Erros de certificado de cliente
Remova --cert client.crt --key client.key e o handshake TLS deve falhar antes que o handler de upload
seja executado. Um certificado de cliente não confiável também falha. O código exato do cURL para um
handshake rejeitado pode variar conforme a versão do TLS e o backend; ele é diferente de uma
rejeição HTTP. Algumas builds relatam um erro de envio ou de recebimento em vez de uma mensagem
específica de certificado.
Já o erro 58 indica problemas ao carregar ou usar as credenciais locais do cliente.
Verifique se o certificado está em PEM, se a chave privada corresponde a ele e se sua conta consegue
ler os dois arquivos. Use as opções de certificado adequadas ao seu backend TLS. No
cURL 8.22.0, um arquivo de chave ausente é rejeitado antes, com o código 43 e um
diagnóstico de carregamento de arquivo.
Rejeição HTTP
Altere /upload para /missing mantendo os certificados válidos. O TLS é bem-sucedido, mas o
servidor retorna HTTP 404 e o cURL sai com 22. Um arquivo acima de 1 MiB é
rejeitado com HTTP 413. Nenhum dos casos substitui received.bin. Verifique o corpo da
resposta atual em mtls-demo/response.txt para essas falhas HTTP; trocar os certificados não corrige um
caminho errado nem um arquivo grande demais.
Boas práticas de segurança
Mantenha ca.key, server.key e client.key privados, incluindo qualquer arquivo PEM combinado
que contenha uma chave. Não faça commit deles nem faça upload do diretório da demonstração. Encerre o
receptor com Ctrl+C ao terminar e, depois, remova o diretório descartável após verificar que ele não
contém nada que você queira manter.
Como lidar com frases secretas de certificados
As chaves da demonstração não são criptografadas para que o exercício local rode sem prompts. Com
uma chave PEM criptografada e o backend OpenSSL, o cURL pode solicitar a frase secreta. Nunca
acrescente a frase secreta a --cert nem a passe como argumento de linha de comando: isso pode
expô-la no histórico do shell ou nos argumentos do processo. Tarefas não supervisionadas precisam de
um mecanismo separado de entrega de segredos, como um gerenciador de segredos e um arquivo de
credenciais restrito, com acesso limitado à conta de serviço.
Compatibilidade de versões
O passo a passo local completo foi executado no Linux com estas combinações:
| Ambiente | cURL e seu backend TLS | CLI do OpenSSL | Python |
|---|---|---|---|
| Contêiner Ubuntu 24.04 | cURL 8.5.0, OpenSSL 3.0.13 | 3.0.13 | 3.12.3 |
| Host baseado em Arch | cURL 8.22.0, OpenSSL 3.6.4 | 3.6.4 | 3.14.7 |
Estas verificações cobrem os bytes do arquivo e o comportamento de falha com o receptor local. Windows, macOS, outros backends TLS e serviços de upload em produção exigem verificação própria.
