Carga directa de archivos de Amazon S3 desde el navegador del cliente: divulgación de la clave privada

159

Estoy implementando una carga directa de archivos desde la máquina del cliente a Amazon S3 a través de la API REST usando solo JavaScript, sin ningún código del lado del servidor. Todo funciona bien, pero una cosa me preocupa ...

Cuando envío una solicitud a la API REST de Amazon S3, necesito firmar la solicitud y poner una firma en el Authenticationencabezado. Para crear una firma, debo usar mi clave secreta. Pero todo sucede en el lado del cliente, por lo que la clave secreta se puede revelar fácilmente desde la fuente de la página (incluso si ofusco / cifro mis fuentes).

¿Cómo puedo manejar esto? ¿Y es un problema en absoluto? ¿Tal vez pueda limitar el uso específico de la clave privada solo a las llamadas a la API REST desde un origen CORS específico y solo a los métodos PUT y POST o tal vez vincular la clave solo a S3 y un bucket específico? ¿Puede haber otros métodos de autenticación?

La solución "sin servidor" es ideal, pero puedo considerar involucrar algún procesamiento en el servidor, excluyendo cargar un archivo a mi servidor y luego enviarlo a S3.

Olegas
fuente
77
Muy simple: no almacene ningún secreto del lado del cliente. Deberá involucrar a un servidor para firmar la solicitud.
Ray Nicholus
1
También encontrará que firmar y codificar en base 64 estas solicitudes es mucho más fácil en el lado del servidor. No parece irrazonable involucrar a un servidor aquí en absoluto. Puedo entender que no quiero enviar todos los bytes del archivo a un servidor y luego hasta S3, pero hay muy pocos beneficios al firmar las solicitudes del lado del cliente, especialmente porque será un poco desafiante y potencialmente lento para el lado del cliente (en javascript).
Ray Nicholus
55
Es 2016, cuando la arquitectura sin servidor se hizo bastante popular, es posible cargar archivos directamente a S3 con la ayuda de AWS Lambda. Vea mi respuesta a una pregunta similar: stackoverflow.com/a/40828683/2504317 Básicamente, usted tendría una función Lambda como una URL que puede cargar la firma de API para cada archivo, y su javascript del lado del cliente simplemente hace un HTTP PUT al URL pre-firmada. He escrito un componente Vue haciendo tales cosas, el código relacionado con la carga de S3 es independiente de la biblioteca, eche un vistazo y capte la idea.
KF Lin
Otra tercera parte para la carga HTTP / S POST en cualquier depósito S3. JS3Upload HTML5 puro: jfileupload.com/products/js3upload-html5/index.html
JFU

Respuestas:

215

Creo que lo que quieres son cargas basadas en navegador usando POST.

Básicamente, necesita un código del lado del servidor, pero todo lo que hace es generar políticas firmadas. Una vez que el código del lado del cliente tiene la política firmada, puede cargar usando POST directamente a S3 sin que los datos pasen por su servidor.

Aquí están los enlaces de documentos oficiales:

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

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

La política firmada iría en su html en una 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 la acción FORM está enviando el archivo directamente a S3 , no a través de su servidor.

Cada vez que uno de sus usuarios quiera cargar un archivo, debe crear el POLICYy SIGNATUREen su servidor. Devuelve la página al navegador del usuario. El usuario puede cargar un archivo directamente a S3 sin pasar por su servidor.

Cuando firma la política, normalmente hace que la política caduque después de unos minutos. Esto obliga a sus usuarios a hablar con su servidor antes de cargarlos. Esto le permite controlar y limitar las cargas si lo desea.

Los únicos datos que van o salen de su servidor son las URL firmadas. Sus claves secretas permanecen secretas en el servidor.

secretmike
fuente
14
tenga en cuenta que esto usa Signature v2 que pronto será reemplazado por v4: docs.aws.amazon.com/AmazonS3/latest/API/…
Jörn Berkefeld
9
Asegúrese de agregar ${filename}al nombre de la clave, así que para el ejemplo anterior, en user/eric/${filename}lugar de solo user/eric. Si user/erices una carpeta ya existente, la carga fallará silenciosamente (incluso será redirigido a success_action_redirect) y el contenido cargado no estará allí. Acabo de pasar horas depurando esto pensando que era un problema de permiso.
Balint Erdi
@secretmike Si recibiste un tiempo de espera al hacer este método, ¿cómo recomendarías circunnavegar eso?
Viaje el
1
@Trip Dado que el navegador está enviando el archivo a S3, deberá detectar el tiempo de espera en Javascript e iniciar un reintento usted mismo.
secretmike
@secretmike Eso huele a un ciclo de bucle infinito. Como el tiempo de espera se repetirá indefinidamente para cualquier archivo de más de 10 / mbs.
Viaje el
40

Puede hacer esto con AWS S3 Cognito, pruebe este enlace aquí:

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

Prueba también este código

Simplemente cambie Region, IdentityPoolId y su nombre de depósito

<!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 más detalles, consulte - Github
Joomler
fuente
¿Esto soporta múltiples imágenes?
user2722667
@ user2722667 sí, lo hace.
Joomler
@Joomler Hola, gracias, pero estoy enfrentando este problema en firefox RequestTimeout Su conexión de socket al servidor no se leyó ni se escribió en el período de tiempo de espera. Las conexiones inactivas se cerrarán y el archivo no se cargará en S3.Pueden ayudarme a solucionar este problema
Gracias
1
@usama, ¿puede abrir el problema en el github porque el problema no está claro para mí
Joomler
@Joomler perdón por la respuesta tardía aquí, he abierto un problema en GitHub, por favor, eche un vistazo a esto Gracias. github.com/aws/aws-sdk-php/issues/1332
usama
16

Estás diciendo que quieres una solución "sin servidor". Pero eso significa que no tiene la capacidad de poner ninguno de "su" código en el bucle. (NOTA: una vez que entregue su código a un cliente, ahora es "su" código). Bloquear CORS no va a ayudar: las personas pueden escribir fácilmente una herramienta no basada en web (o un proxy basado en web) que agrega el encabezado CORS correcto para abusar de su sistema.

El gran problema es que no puedes diferenciar entre los diferentes usuarios. No puede permitir que un usuario enumere / acceda a sus archivos, pero puede evitar que otros lo hagan. Si detecta abuso, no hay nada que pueda hacer al respecto, excepto cambiar la clave. (Que el atacante presumiblemente puede obtener de nuevo).

Su mejor opción es crear un "usuario de IAM" con una clave para su cliente de JavaScript. Solo dale acceso de escritura a un solo cubo. (pero idealmente, no habilite la operación ListBucket, eso lo hará más atractivo para los atacantes).

Si tuviera un servidor (incluso una microinstancia simple a $ 20 / mes), podría firmar las claves en su servidor mientras monitorea / previene el abuso en tiempo real. Sin un servidor, lo mejor que puede hacer es monitorear periódicamente el abuso después del hecho. Esto es lo que haría:

1) gire periódicamente las claves para ese usuario de IAM: todas las noches, genere una nueva clave para ese usuario de IAM y reemplace la clave más antigua. Como hay 2 claves, cada clave será válida por 2 días.

2) habilite el registro S3 y descargue los registros cada hora. Establezca alertas en "demasiadas cargas" y "demasiadas descargas". Deberá verificar tanto el tamaño total del archivo como la cantidad de archivos cargados. Y querrá controlar tanto los totales globales como también los totales por dirección IP (con un umbral más bajo).

Estas comprobaciones se pueden hacer "sin servidor" porque puede ejecutarlas en su escritorio. (es decir, S3 hace todo el trabajo, estos procesos solo están ahí para alertarlo sobre el abuso de su depósito S3 para que no reciba una factura gigante de AWS al final del mes).

ValienteNuevoMoneda
fuente
3
Hombre, olvidé lo complicadas que eran las cosas antes de Lambda.
Ryan Shillington
10

Al agregar más información a la respuesta aceptada, puede consultar mi blog para ver una versión en ejecución del código, utilizando la versión 4 de AWS Signature.

Resumiremos aquí:

Tan pronto como el usuario seleccione un archivo para cargar, haga lo siguiente: 1. Llame al servidor web para iniciar un servicio para generar los parámetros necesarios

  1. En este servicio, llame al servicio AWS IAM para obtener crédito temporal

  2. Una vez que tenga la credibilidad, cree una política de depósito (cadena codificada en base 64). Luego, firme la política del depósito con la clave de acceso secreta temporal para generar la firma final

  3. enviar los parámetros necesarios de vuelta a la interfaz de usuario

  4. Una vez que se reciba esto, cree un objeto de formulario html, establezca los parámetros requeridos y PUBLICARLO

Para obtener información detallada, consulte https://wordpress1763.wordpress.com/2016/10/03/browser-based-upload-aws-signature-version-4/

RajeevJ
fuente
55
Pasé un día entero tratando de resolver esto en Javascript, y esta respuesta me dice exactamente cómo hacerlo usando XMLhttprequest. Estoy muy sorprendido de que te hayan votado mal. El OP solicitó JavaScript y obtuvo formularios en las respuestas recomendadas. Caramba. Gracias por esta respuesta!
Paul S
Por cierto, el superagente tiene serios problemas CORS, por lo que xmlhttprequest parece ser la única forma razonable de hacer esto en este momento
Paul S
4

Para crear una firma, debo usar mi clave secreta. Pero todo sucede en el lado del cliente, por lo que la clave secreta se puede revelar fácilmente desde la fuente de la página (incluso si ofusco / cifro mis fuentes).

Aquí es donde has entendido mal. La razón por la que se usan las firmas digitales es para que pueda verificar que algo es correcto sin revelar su clave secreta. En este caso, la firma digital se utiliza para evitar que el usuario modifique la política que configuró para la publicación del formulario.

Las firmas digitales como la de aquí se utilizan para la seguridad en toda la web. Si alguien (¿NSA?) Realmente pudiera romperlos, tendrían objetivos mucho más grandes que su cubo S3 :)

OlliM
fuente
2
pero un robot puede intentar cargar archivos ilimitados rápidamente. ¿Puedo establecer una política de archivos máximos por cubo?
Dejell
3

He dado un código simple para cargar archivos desde el navegador Javascript a AWS S3 y enumerar todos los archivos en el cubo S3.

Pasos:

  1. Para saber cómo crear Create IdentityPoolId http://docs.aws.amazon.com/cognito/latest/developerguide/identity-pools.html

    1. Pase a la página de la consola S3 y abra la configuración de los cors desde las propiedades del depósito y escriba el siguiente código XML en eso.

      <?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. Cree un archivo HTML que contenga el siguiente código, cambie las credenciales, abra el archivo en el navegador y disfrute.

      <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
fuente
2
¿Nadie podría usar mi "IdentityPoolId" para cargar archivos en mi bucket de S3? ¿Cómo impide esta solución que un tercero simplemente copie mi "IdentityPoolId" y cargue muchos archivos en mi bucket de S3?
Sahil
1
stackoverflow.com/users/4535741/sahil Puede evitar la carga de datos / archivos desde otros dominios estableciendo la configuración CORS adecuada en el depósito S3. Entonces, incluso si alguien accedió a su ID de grupo de identidad, no puede manipular sus archivos de depósito s3.
Nilesh Pawar
2

Si no tiene ningún código del lado del servidor, su seguridad depende de la seguridad del acceso a su código JavaScript en el lado del cliente (es decir, todos los que tienen el código pueden cargar algo).

Por lo tanto, recomendaría, simplemente crear un bucket especial S3 que sea de escritura pública (pero no legible), para que no necesite ningún componente firmado en el lado del cliente.

El nombre del depósito (un GUID, por ejemplo) será su única defensa contra cargas maliciosas (pero un atacante potencial no podría usar su depósito para transferir datos, ya que solo se escribe a él)

Ruediger Jungbeck
fuente
1

Así es como se genera un documento de política utilizando el nodo y sin 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;

El objeto de configuración utilizado se almacena en SSM Parameter Store y tiene este aspecto

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

Si está dispuesto a utilizar un servicio de terceros, auth0.com admite esta integración. El servicio auth0 intercambia una autenticación de servicio SSO de terceros por un token de sesión temporal de AWS con permisos limitados.

Consulte: https://github.com/auth0-samples/auth0-s3-sample/
y la documentación de auth0.

Jason
fuente
1
Según tengo entendido, ¿ahora tenemos Cognito para eso?
Vitaly Zdanevich