Upload direto de arquivos do Amazon S3 a partir do navegador do cliente - divulgação de chave privada

159

Estou implementando um upload direto de arquivo da máquina cliente para o Amazon S3 via API REST usando apenas JavaScript, sem nenhum código do lado do servidor. Tudo funciona bem, mas uma coisa está me preocupando ...

Quando envio uma solicitação para a API REST do Amazon S3, preciso assinar a solicitação e colocar uma assinatura no Authenticationcabeçalho. Para criar uma assinatura, devo usar minha chave secreta. Como tudo acontece do lado do cliente, a chave secreta pode ser facilmente revelada na fonte da página (mesmo que eu ofusque / criptografe minhas fontes).

Como posso lidar com isso? E isso é um problema? Talvez eu possa limitar o uso de chave privada específica apenas às chamadas da API REST de uma origem CORS específica e apenas aos métodos PUT e POST ou talvez vincular a chave apenas ao S3 e ao bucket específico? Pode haver outros métodos de autenticação?

A solução "sem servidor" é ideal, mas posso considerar envolver algum processamento no servidor, excluindo o upload de um arquivo para o meu servidor e enviando para o S3.

Olegas
fonte
7
Muito simples: não guarde segredos do lado do cliente. Você precisará envolver um servidor para assinar a solicitação.
Raio Nicholus
1
Você também descobrirá que assinar e codificar na base 64 essas solicitações é muito mais fácil no servidor. Não parece irracional envolver um servidor aqui. Entendo que não quero enviar todos os bytes de arquivo para um servidor e até o S3, mas há muito pouco benefício em assinar as solicitações no lado do cliente, especialmente porque isso será um pouco desafiador e potencialmente lento para executar no lado do cliente (em javascript).
Raio Nicholus
5
É 2016, quando a arquitetura sem servidor se tornou bastante popular, o upload de arquivos diretamente para o S3 é possível com a ajuda da AWS Lambda. Veja minha resposta para uma pergunta semelhante: stackoverflow.com/a/40828683/2504317 Basicamente, você teria uma função Lambda como uma API que assina o URL de upload para cada arquivo, e seu javascript do lado do cliente apenas faz um HTTP PUT para o arquivo URL pré-assinado. Eu escrevi um componente Vue fazendo essas coisas, o código relacionado ao upload do S3 é independente da biblioteca, dê uma olhada e entenda.
KF Lin
Outro terceiro para upload de HTTP / S POST em qualquer bucket do S3. JS3Upload HTML5 pura: jfileupload.com/products/js3upload-html5/index.html
JFU

Respostas:

215

Eu acho que o que você deseja é Uploads baseados em navegador usando POST.

Basicamente, você precisa de código do lado do servidor, mas tudo o que faz é gerar políticas assinadas. Depois que o código do lado do cliente tiver a política assinada, ele poderá fazer o upload usando o POST diretamente para o S3 sem que os dados passem pelo servidor.

Aqui estão os links oficiais dos documentos:

Diagrama: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingHTTPPOST.html

Código de exemplo: http://docs.aws.amazon.com/AmazonS3/latest/dev/HTTPPOSTExamples.html

A política assinada entraria no seu html de uma forma como esta:

<html>
  <head>
    ...
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    ...
  </head>
  <body>
  ...
  <form action="http://johnsmith.s3.amazonaws.com/" method="post" enctype="multipart/form-data">
    Key to upload: <input type="input" name="key" value="user/eric/" /><br />
    <input type="hidden" name="acl" value="public-read" />
    <input type="hidden" name="success_action_redirect" value="http://johnsmith.s3.amazonaws.com/successful_upload.html" />
    Content-Type: <input type="input" name="Content-Type" value="image/jpeg" /><br />
    <input type="hidden" name="x-amz-meta-uuid" value="14365123651274" />
    Tags for File: <input type="input" name="x-amz-meta-tag" value="" /><br />
    <input type="hidden" name="AWSAccessKeyId" value="AKIAIOSFODNN7EXAMPLE" />
    <input type="hidden" name="Policy" value="POLICY" />
    <input type="hidden" name="Signature" value="SIGNATURE" />
    File: <input type="file" name="file" /> <br />
    <!-- The elements after this will be ignored -->
    <input type="submit" name="submit" value="Upload to Amazon S3" />
  </form>
  ...
</html>

Observe que a ação FORM está enviando o arquivo diretamente para o S3 - não pelo servidor.

Toda vez que um de seus usuários deseja fazer upload de um arquivo, você cria o POLICYe SIGNATUREno seu servidor. Você retorna a página ao navegador do usuário. O usuário pode fazer o upload de um arquivo diretamente para o S3 sem passar pelo servidor.

Quando você assina a política, normalmente faz com que a política expire após alguns minutos. Isso força seus usuários a conversar com seu servidor antes de fazer o upload. Isso permite monitorar e limitar os uploads, se você desejar.

Os únicos dados que entram ou saem do servidor são os URLs assinados. Suas chaves secretas permanecem secretas no servidor.

secretmike
fonte
14
observe que isso usa a assinatura v2, que será substituída em breve pela v4: docs.aws.amazon.com/AmazonS3/latest/API/…
Jörn Berkefeld
9
Certifique-se de adicionar ${filename}o nome da chave, portanto, para o exemplo acima, em user/eric/${filename}vez de apenas user/eric. Se user/ericjá existir uma pasta, o upload falhará silenciosamente (você até será redirecionado para o success_action_redirect) e o conteúdo carregado não estará lá. Apenas passei horas depurando esse pensamento, que era um problema de permissão.
Balint Erdi
@secretmike Se você recebeu um tempo limite ao fazer esse método, como recomendaria uma circunavegação?
Viagem
1
@ Trip Como o navegador está enviando o arquivo para o S3, você precisará detectar o tempo limite em Javascript e iniciar uma nova tentativa.
secretmike
@secretmike Isso cheira a um ciclo de loop infinito. Como o tempo limite se repetirá indefinidamente para qualquer arquivo acima de 10 / mbs.
Viagem
40

Você pode fazer isso pelo AWS S3 Cognito, tente este link aqui:

http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/browser-examples.html#Amazon_S3

Experimente também este código

Basta alterar Region, IdentityPoolId e Your bucket name

<!DOCTYPE html>
<html>

<head>
    <title>AWS S3 File Upload</title>
    <script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</head>

<body>
    <input type="file" id="file-chooser" />
    <button id="upload-button">Upload to S3</button>
    <div id="results"></div>
    <script type="text/javascript">
    AWS.config.region = 'your-region'; // 1. Enter your region

    AWS.config.credentials = new AWS.CognitoIdentityCredentials({
        IdentityPoolId: 'your-IdentityPoolId' // 2. Enter your identity pool
    });

    AWS.config.credentials.get(function(err) {
        if (err) alert(err);
        console.log(AWS.config.credentials);
    });

    var bucketName = 'your-bucket'; // Enter your bucket name
    var bucket = new AWS.S3({
        params: {
            Bucket: bucketName
        }
    });

    var fileChooser = document.getElementById('file-chooser');
    var button = document.getElementById('upload-button');
    var results = document.getElementById('results');
    button.addEventListener('click', function() {

        var file = fileChooser.files[0];

        if (file) {

            results.innerHTML = '';
            var objKey = 'testing/' + file.name;
            var params = {
                Key: objKey,
                ContentType: file.type,
                Body: file,
                ACL: 'public-read'
            };

            bucket.putObject(params, function(err, data) {
                if (err) {
                    results.innerHTML = 'ERROR: ' + err;
                } else {
                    listObjs();
                }
            });
        } else {
            results.innerHTML = 'Nothing to upload.';
        }
    }, false);
    function listObjs() {
        var prefix = 'testing';
        bucket.listObjects({
            Prefix: prefix
        }, function(err, data) {
            if (err) {
                results.innerHTML = 'ERROR: ' + err;
            } else {
                var objKeys = "";
                data.Contents.forEach(function(obj) {
                    objKeys += obj.Key + "<br>";
                });
                results.innerHTML = objKeys;
            }
        });
    }
    </script>
</body>

</html>

Para mais detalhes, verifique - Github
Joomler
fonte
Isso suporta várias imagens?
user2722667
@ user2722667 sim, sim.
Joomler #
@ Joomler Oi Obrigado, mas estou enfrentando esse problema no firefox RequestTimeout Sua conexão de soquete com o servidor não foi lida ou gravada no período de tempo limite. Conexões ociosas será fechada eo arquivo é não carregar em S3.Can você Por favor, ajude-me de como posso corrigir isso issue.Thanks
Usama
1
@usama você pode agradar aberto a questão no github porque questão não é claro para mim
Joomler
@ Joomler desculpe pela resposta tardia aqui eu abri um problema no GitHub, por favor dê uma olhada neste Obrigado. github.com/aws/aws-sdk-php/issues/1332
Osama
16

Você está dizendo que deseja uma solução "sem servidor". Mas isso significa que você não tem capacidade de inserir nenhum código "seu" no loop. (OBSERVAÇÃO: Depois de fornecer seu código a um cliente, ele é o código "deles" agora.) Bloquear o CORS não ajudará: as pessoas podem escrever facilmente uma ferramenta não baseada na Web (ou um proxy baseado na Web) que adiciona o cabeçalho CORS correto para abusar do seu sistema.

O grande problema é que você não pode diferenciar entre os diferentes usuários. Você não pode permitir que um usuário liste / acesse seus arquivos, mas impede que outros o façam. Se você detectar abuso, não há nada que possa fazer a respeito, exceto alterar a chave. (Que o atacante pode presumivelmente apenas recuperar de novo.)

Sua melhor aposta é criar um "usuário IAM" com uma chave para o seu cliente javascript. Apenas conceda acesso de gravação a apenas um depósito. (mas, idealmente, não ative a operação ListBucket, que a tornará mais atraente para os invasores.)

Se você tivesse um servidor (mesmo uma micro instância simples a US $ 20 / mês), poderá assinar as chaves no servidor enquanto monitora / evita abusos em tempo real. Sem um servidor, o melhor que você pode fazer é monitorar periodicamente o abuso após o fato. Aqui está o que eu faria:

1) gire periodicamente as chaves para esse usuário do IAM: Toda noite, gere uma nova chave para esse usuário do IAM e substitua a chave mais antiga. Como existem 2 chaves, cada chave será válida por 2 dias.

2) habilite o log S3 e faça o download dos logs a cada hora. Defina alertas em "muitos envios" e "muitos downloads". Você deseja verificar o tamanho total do arquivo e o número de arquivos enviados. E você desejará monitorar os totais globais e também os totais por endereço IP (com um limite mais baixo).

Essas verificações podem ser feitas "sem servidor" porque você pode executá-las na área de trabalho. (ou seja, o S3 faz todo o trabalho, esses processos servem apenas para alertá-lo sobre o abuso do seu bucket do S3, para que você não receba uma fatura gigante da AWS no final do mês.)

BraveNewCurrency
fonte
3
Cara, eu esqueci como as coisas eram complicadas antes de Lambda.
Ryan Shillington
10

Adicionando mais informações à resposta aceita, você pode consultar o meu blog para ver uma versão em execução do código, usando o AWS Signature versão 4.

Resumirá aqui:

Assim que o usuário selecionar um arquivo a ser carregado, faça o seguinte: 1. Faça uma chamada para o servidor da web para iniciar um serviço para gerar parâmetros necessários

  1. Nesse serviço, ligue para o serviço AWS IAM para obter credenciais temporárias

  2. Depois de obter o cred, crie uma política de bucket (sequência codificada de base 64). Em seguida, assine a política de bucket com a chave de acesso secreto temporário para gerar a assinatura final

  3. envie os parâmetros necessários de volta para a interface do usuário

  4. Depois que isso for recebido, crie um objeto de formulário html, defina os parâmetros necessários e POST.

Para informações detalhadas, consulte https://wordpress1763.wordpress.com/2016/10/03/browser-based-upload-aws-signature-version-4/

RajeevJ
fonte
5
Passei um dia inteiro tentando descobrir isso em Javascript, e essa resposta me diz exatamente como fazer isso usando XMLhttprequest. Estou muito surpreso que você tenha votado mal. O OP solicitou javascript e obteve formulários nas respostas recomendadas. Minha nossa. Obrigado por esta resposta!
Paul S
BTW superagent tem sérias questões CORS, então xmlhttprequest parece para e a única maneira razoável fazer isso agora
Paul S
4

Para criar uma assinatura, devo usar minha chave secreta. Como tudo acontece do lado do cliente, a chave secreta pode ser facilmente revelada na fonte da página (mesmo que eu ofusque / criptografe minhas fontes).

É aqui que você entendeu mal. O motivo pelo qual as assinaturas digitais são usadas é para que você possa verificar se algo está correto sem revelar sua chave secreta. Nesse caso, a assinatura digital é usada para impedir que o usuário modifique a política que você definiu para a postagem do formulário.

Assinaturas digitais como a aqui são usadas para segurança em toda a web. Se alguém (NSA?) Realmente fosse capaz de quebrá-los, eles teriam alvos muito maiores do que o seu bucket S3 :)

OlliM
fonte
2
mas um robô pode tentar fazer upload de arquivos ilimitados rapidamente. posso definir uma política de arquivos máximos por intervalo?
Dejell 06/03
3

Forneci um código simples para fazer upload de arquivos do navegador Javascript para o AWS S3 e listar todos os arquivos no bucket do S3.

Passos:

  1. Para saber como criar o Create IdentityPoolId http://docs.aws.amazon.com/cognito/latest/developerguide/identity-pools.html

    1. Vá para a página do console do S3 e abra a configuração de cors a partir das propriedades do bucket e escreva o código XML a seguir.

      <?xml version="1.0" encoding="UTF-8"?>
      <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
       <CORSRule>    
        <AllowedMethod>GET</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <AllowedMethod>DELETE</AllowedMethod>
        <AllowedMethod>HEAD</AllowedMethod>
        <AllowedHeader>*</AllowedHeader>
       </CORSRule>
      </CORSConfiguration>
      
    2. Crie um arquivo HTML contendo o seguinte código, altere as credenciais, abra o arquivo no navegador e aproveite.

      <script type="text/javascript">
       AWS.config.region = 'ap-north-1'; // Region
       AWS.config.credentials = new AWS.CognitoIdentityCredentials({
       IdentityPoolId: 'ap-north-1:*****-*****',
       });
       var bucket = new AWS.S3({
       params: {
       Bucket: 'MyBucket'
       }
       });
      
       var fileChooser = document.getElementById('file-chooser');
       var button = document.getElementById('upload-button');
       var results = document.getElementById('results');
      
       function upload() {
       var file = fileChooser.files[0];
       console.log(file.name);
      
       if (file) {
       results.innerHTML = '';
       var params = {
       Key: n + '.pdf',
       ContentType: file.type,
       Body: file
       };
       bucket.upload(params, function(err, data) {
       results.innerHTML = err ? 'ERROR!' : 'UPLOADED.';
       });
       } else {
       results.innerHTML = 'Nothing to upload.';
       }    }
      </script>
      <body>
       <input type="file" id="file-chooser" />
       <input type="button" onclick="upload()" value="Upload to S3">
       <div id="results"></div>
      </body>
      
Nilesh Pawar
fonte
2
Ninguém seria capaz de usar meu "IdentityPoolId" para fazer upload de arquivos para o meu bucket S3. Como esta solução impede que terceiros copiem meu "IdentityPoolId" e carreguem muitos arquivos no meu bucket S3?
Sahil
1
stackoverflow.com/users/4535741/sahil Você pode impedir o upload de dados / arquivos de outros domínios definindo as configurações apropriadas do CORS no bucket do S3. Portanto, mesmo que alguém tenha acessado seu ID de pool de identidades, eles não podem manipular seus arquivos de bucket s3.
Nilesh Pawar
2

Se você não possui nenhum código do lado do servidor, sua segurança depende da segurança do acesso ao seu código JavaScript no lado do cliente (ou seja, todos os que possuem o código podem enviar algo).

Então, eu recomendaria, simplesmente criar um bucket S3 especial que possa ser gravado em público (mas não legível), para que você não precise de nenhum componente assinado no lado do cliente.

O nome do depósito (por exemplo, um GUID) será sua única defesa contra envios mal-intencionados (mas um invasor em potencial não poderá usar seu depósito para transferir dados, porque ele é gravado apenas para ele)

Ruediger Jungbeck
fonte
1

Aqui está como você gera um documento de política usando nó e sem servidor

"use strict";

const uniqid = require('uniqid');
const crypto = require('crypto');

class Token {

    /**
     * @param {Object} config SSM Parameter store JSON config
     */
    constructor(config) {

        // Ensure some required properties are set in the SSM configuration object
        this.constructor._validateConfig(config);

        this.region = config.region; // AWS region e.g. us-west-2
        this.bucket = config.bucket; // Bucket name only
        this.bucketAcl = config.bucketAcl; // Bucket access policy [private, public-read]
        this.accessKey = config.accessKey; // Access key
        this.secretKey = config.secretKey; // Access key secret

        // Create a really unique videoKey, with folder prefix
        this.key = uniqid() + uniqid.process();

        // The policy requires the date to be this format e.g. 20181109
        const date = new Date().toISOString();
        this.dateString = date.substr(0, 4) + date.substr(5, 2) + date.substr(8, 2);

        // The number of minutes the policy will need to be used by before it expires
        this.policyExpireMinutes = 15;

        // HMAC encryption algorithm used to encrypt everything in the request
        this.encryptionAlgorithm = 'sha256';

        // Client uses encryption algorithm key while making request to S3
        this.clientEncryptionAlgorithm = 'AWS4-HMAC-SHA256';
    }

    /**
     * Returns the parameters that FE will use to directly upload to s3
     *
     * @returns {Object}
     */
    getS3FormParameters() {
        const credentialPath = this._amazonCredentialPath();
        const policy = this._s3UploadPolicy(credentialPath);
        const policyBase64 = new Buffer(JSON.stringify(policy)).toString('base64');
        const signature = this._s3UploadSignature(policyBase64);

        return {
            'key': this.key,
            'acl': this.bucketAcl,
            'success_action_status': '201',
            'policy': policyBase64,
            'endpoint': "https://" + this.bucket + ".s3-accelerate.amazonaws.com",
            'x-amz-algorithm': this.clientEncryptionAlgorithm,
            'x-amz-credential': credentialPath,
            'x-amz-date': this.dateString + 'T000000Z',
            'x-amz-signature': signature
        }
    }

    /**
     * Ensure all required properties are set in SSM Parameter Store Config
     *
     * @param {Object} config
     * @private
     */
    static _validateConfig(config) {
        if (!config.hasOwnProperty('bucket')) {
            throw "'bucket' is required in SSM Parameter Store Config";
        }
        if (!config.hasOwnProperty('region')) {
            throw "'region' is required in SSM Parameter Store Config";
        }
        if (!config.hasOwnProperty('accessKey')) {
            throw "'accessKey' is required in SSM Parameter Store Config";
        }
        if (!config.hasOwnProperty('secretKey')) {
            throw "'secretKey' is required in SSM Parameter Store Config";
        }
    }

    /**
     * Create a special string called a credentials path used in constructing an upload policy
     *
     * @returns {String}
     * @private
     */
    _amazonCredentialPath() {
        return this.accessKey + '/' + this.dateString + '/' + this.region + '/s3/aws4_request';
    }

    /**
     * Create an upload policy
     *
     * @param {String} credentialPath
     *
     * @returns {{expiration: string, conditions: *[]}}
     * @private
     */
    _s3UploadPolicy(credentialPath) {
        return {
            expiration: this._getPolicyExpirationISODate(),
            conditions: [
                {bucket: this.bucket},
                {key: this.key},
                {acl: this.bucketAcl},
                {success_action_status: "201"},
                {'x-amz-algorithm': 'AWS4-HMAC-SHA256'},
                {'x-amz-credential': credentialPath},
                {'x-amz-date': this.dateString + 'T000000Z'}
            ],
        }
    }

    /**
     * ISO formatted date string of when the policy will expire
     *
     * @returns {String}
     * @private
     */
    _getPolicyExpirationISODate() {
        return new Date((new Date).getTime() + (this.policyExpireMinutes * 60 * 1000)).toISOString();
    }

    /**
     * HMAC encode a string by a given key
     *
     * @param {String} key
     * @param {String} string
     *
     * @returns {String}
     * @private
     */
    _encryptHmac(key, string) {
        const hmac = crypto.createHmac(
            this.encryptionAlgorithm, key
        );
        hmac.end(string);

        return hmac.read();
    }

    /**
     * Create an upload signature from provided params
     * https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html#signing-request-intro
     *
     * @param policyBase64
     *
     * @returns {String}
     * @private
     */
    _s3UploadSignature(policyBase64) {
        const dateKey = this._encryptHmac('AWS4' + this.secretKey, this.dateString);
        const dateRegionKey = this._encryptHmac(dateKey, this.region);
        const dateRegionServiceKey = this._encryptHmac(dateRegionKey, 's3');
        const signingKey = this._encryptHmac(dateRegionServiceKey, 'aws4_request');

        return this._encryptHmac(signingKey, policyBase64).toString('hex');
    }
}

module.exports = Token;

O objeto de configuração usado é armazenado no SSM Parameter Store e se parece com isso

{
    "bucket": "my-bucket-name",
    "region": "us-west-2",
    "bucketAcl": "private",
    "accessKey": "MY_ACCESS_KEY",
    "secretKey": "MY_SECRET_ACCESS_KEY",
}
Samir Patel
fonte
0

Se você estiver disposto a usar um serviço de terceiros, o auth0.com suporta essa integração. O serviço auth0 troca uma autenticação de serviço SSO de terceiros por um token de sessão temporário da AWS com permissões limitadas.

Consulte: https://github.com/auth0-samples/auth0-s3-sample/
e a documentação auth0.

Jason
fonte
1
Pelo que entendi - agora temos o Cognito para isso?
21818 Vitaly Zdanevich