Téléchargement direct de fichiers Amazon S3 à partir du navigateur client - divulgation de clé privée

159

J'implémente un téléchargement direct de fichiers depuis la machine cliente vers Amazon S3 via l'API REST en utilisant uniquement JavaScript, sans aucun code côté serveur. Tout fonctionne bien mais une chose m'inquiète ...

Lorsque j'envoie une demande à l'API REST Amazon S3, je dois signer la demande et mettre une signature dans l'en- Authenticationtête. Pour créer une signature, je dois utiliser ma clé secrète. Mais tout se passe du côté client, donc la clé secrète peut être facilement révélée à partir de la source de la page (même si j'obscurcit / crypte mes sources).

Comment puis-je gérer cela? Et est-ce vraiment un problème? Peut-être que je peux limiter l'utilisation de clé privée spécifique uniquement aux appels d'API REST à partir d'une origine CORS spécifique et uniquement aux méthodes PUT et POST ou peut-être lier la clé uniquement à S3 et à un compartiment spécifique? Peut-être existe-t-il d'autres méthodes d'authentification?

La solution «sans serveur» est idéale, mais je peux envisager d'impliquer certains traitements côté serveur, à l'exclusion du téléchargement d'un fichier sur mon serveur, puis de l'envoi vers S3.

Olegas
la source
7
Très simple: ne stockez aucun secret côté client. Vous devrez impliquer un serveur pour signer la demande.
Ray Nicholus du
1
Vous constaterez également que la signature et l'encodage en base 64 de ces demandes sont beaucoup plus faciles côté serveur. Il ne semble pas du tout déraisonnable d'impliquer un serveur ici. Je peux comprendre de ne pas vouloir envoyer tous les octets de fichier à un serveur, puis à S3, mais il y a très peu d'avantages à signer les demandes côté client, d'autant plus que cela sera un peu difficile et potentiellement lent à faire côté client (en javascript).
Ray Nicholus
5
Nous sommes en 2016, alors que l'architecture sans serveur est devenue très populaire, il est possible de télécharger des fichiers directement sur S3 avec l'aide d'AWS Lambda. Voir ma réponse à une question similaire: stackoverflow.com/a/40828683/2504317 Fondamentalement, vous auriez une fonction Lambda en tant qu'URL de téléchargement de signature d'API pour chaque fichier, et votre javascript côté cliend ne fait qu'un HTTP PUT vers le URL pré-signée. J'ai écrit un composant Vue faisant de telles choses, le code lié au téléchargement S3 est indépendant de la bibliothèque, jetez un œil et obtenez l'idée.
KF Lin
Un autre tiers pour le téléchargement HTTP / S POST dans n'importe quel compartiment S3. JS3Upload pur HTML5: jfileupload.com/products/js3upload-html5/index.html
JFU

Réponses:

216

Je pense que ce que vous voulez, ce sont des téléchargements basés sur un navigateur utilisant POST.

Fondamentalement, vous avez besoin d'un code côté serveur, mais il ne fait que générer des stratégies signées. Une fois que le code côté client a la stratégie signée, il peut télécharger à l'aide de POST directement vers S3 sans que les données passent par votre serveur.

Voici les liens officiels de la documentation:

Schéma: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingHTTPPOST.html

Exemple de code: http://docs.aws.amazon.com/AmazonS3/latest/dev/HTTPPOSTExamples.html

La politique signée irait dans votre html sous une forme comme celle-ci:

<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>

Notez que l'action FORM envoie le fichier directement à S3 - pas via votre serveur.

Chaque fois qu'un de vos utilisateurs souhaite télécharger un fichier, vous créez le POLICYet SIGNATUREsur votre serveur. Vous renvoyez la page dans le navigateur de l'utilisateur. L'utilisateur peut alors télécharger un fichier directement sur S3 sans passer par votre serveur.

Lorsque vous signez la stratégie, vous faites généralement expirer la stratégie après quelques minutes. Cela oblige vos utilisateurs à parler à votre serveur avant le téléchargement. Cela vous permet de surveiller et de limiter les téléchargements si vous le souhaitez.

Les seules données allant vers ou depuis votre serveur sont les URL signées. Vos clés secrètes restent secrètes sur le serveur.

secretmike
la source
14
veuillez noter que cela utilise Signature v2 qui sera bientôt remplacée par la v4: docs.aws.amazon.com/AmazonS3/latest/API/…
Jörn Berkefeld
9
Assurez-vous bien d'ajouter ${filename}au nom de la clé, donc pour l'exemple ci-dessus, user/eric/${filename}au lieu de simplement user/eric. S'il user/erics'agit d'un dossier déjà existant, le téléchargement échouera silencieusement (vous serez même redirigé vers le success_action_redirect) et le contenu téléchargé n'y sera pas. Je viens de passer des heures à déboguer cela en pensant que c'était un problème d'autorisation.
Balint Erdi
@secretmike Si vous avez reçu un délai d'attente pour cette méthode, comment recommanderiez-vous de contourner cela?
Voyage du
1
@Trip Puisque le navigateur envoie le fichier à S3, vous devrez détecter le délai d'expiration en Javascript et lancer une nouvelle tentative vous-même.
secretmike
@secretmike Cela sent comme un cycle de boucle infini. Comme le délai d'expiration va se reproduire indéfiniment pour tout fichier de plus de 10 / mbs.
Voyage du
40

Vous pouvez le faire via AWS S3 Cognito, essayez ce lien ici:

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

Essayez également ce code

Changez simplement la région, l'IdentityPoolId et le nom de votre bucket

<!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>

Pour plus de détails, veuillez vérifier - Github
Joomler
la source
Est-ce que cela prend en charge plusieurs images?
user2722667
@ user2722667 oui.
Joomler
@Joomler Salut Merci mais je suis confronté à ce problème sur Firefox RequestTimeout Votre connexion socket au serveur n'a pas été lue ou écrite dans le délai d'expiration. Les connexions inactives seront fermées et le fichier ne sera pas téléchargé sur S3.Pouvez-vous s'il vous plaît m'aider à savoir comment puis-je résoudre ce problème.Merci
usama
1
@usama pouvez-vous s'il vous plaît ouvrir le problème dans le github car le problème n'est pas clair pour moi
Joomler
@Joomler désolé pour la réponse tardive ici, j'ai ouvert un problème sur GitHub s'il vous plaît jeter un oeil à ce Merci. github.com/aws/aws-sdk-php/issues/1332
usama
16

Vous dites que vous voulez une solution «sans serveur». Mais cela signifie que vous n'avez pas la possibilité de mettre aucun de "votre" code dans la boucle. (REMARQUE: une fois que vous avez donné votre code à un client, c'est «son» code maintenant.) Verrouiller CORS ne va pas aider: les gens peuvent facilement écrire un outil non Web (ou un proxy Web) qui ajoute l'en-tête CORS correct pour abuser de votre système.

Le gros problème est que vous ne pouvez pas différencier les différents utilisateurs. Vous ne pouvez pas autoriser un utilisateur à lister / accéder à ses fichiers, mais empêcher les autres de le faire. Si vous détectez un abus, vous ne pouvez rien y faire sauf changer la clé. (Ce que l'attaquant peut vraisemblablement obtenir à nouveau.)

Votre meilleur pari est de créer un "utilisateur IAM" avec une clé pour votre client javascript. Ne lui donnez l'accès en écriture qu'à un seul compartiment. (mais idéalement, n'activez pas l'opération ListBucket, cela la rendra plus attrayante pour les attaquants.)

Si vous aviez un serveur (même une simple micro-instance à 20 $ / mois), vous pourriez signer les clés sur votre serveur tout en surveillant / prévenant les abus en temps réel. Sans serveur, le mieux que vous puissiez faire est de surveiller périodiquement les abus après coup. Voici ce que je ferais:

1) Faites régulièrement pivoter les clés de cet utilisateur IAM: chaque nuit, générez une nouvelle clé pour cet utilisateur IAM et remplacez la clé la plus ancienne. Puisqu'il y a 2 clés, chaque clé sera valable 2 jours.

2) activez la journalisation S3 et téléchargez les journaux toutes les heures. Définissez des alertes sur "trop ​​de téléchargements" et "trop ​​de téléchargements". Vous voudrez vérifier à la fois la taille totale du fichier et le nombre de fichiers téléchargés. Et vous voudrez surveiller à la fois les totaux globaux, ainsi que les totaux par adresse IP (avec un seuil inférieur).

Ces vérifications peuvent être effectuées "sans serveur" car vous pouvez les exécuter sur votre bureau. (c'est-à-dire que S3 fait tout le travail, ces processus sont juste là pour vous alerter en cas d'abus de votre compartiment S3 afin que vous n'obteniez pas une facture AWS géante à la fin du mois.)

BraveNewCurrency
la source
3
Mec, j'ai oublié à quel point les choses étaient compliquées avant Lambda.
Ryan Shillington
10

En ajoutant plus d'informations à la réponse acceptée, vous pouvez consulter mon blog pour voir une version en cours d'exécution du code, en utilisant AWS Signature version 4.

Va résumer ici:

Dès que l'utilisateur sélectionne un fichier à télécharger, procédez comme suit: 1. Appelez le serveur Web pour lancer un service afin de générer les paramètres requis

  1. Dans ce service, appelez le service AWS IAM pour obtenir des informations temporaires

  2. Une fois que vous avez les informations, créez une stratégie de compartiment (chaîne codée en base 64). Signez ensuite la stratégie de compartiment avec la clé d'accès secrète temporaire pour générer la signature finale

  3. renvoyer les paramètres nécessaires à l'interface utilisateur

  4. Une fois cela reçu, créez un objet de formulaire html, définissez les paramètres requis et POSTEZ-le.

Pour des informations détaillées, veuillez vous référer à https://wordpress1763.wordpress.com/2016/10/03/browser-based-upload-aws-signature-version-4/

RajeevJ
la source
5
J'ai passé une journée entière à essayer de comprendre cela en Javascript, et cette réponse me dit exactement comment faire cela en utilisant XMLhttprequest. Je suis très surpris que vous ayez obtenu un vote négatif. Le PO a demandé du javascript et a obtenu des formulaires dans les réponses recommandées. Bon chagrin. Merci pour cette réponse!
Paul S
BTW superagent a de sérieux problèmes CORS, donc xmlhttprequest semble être le seul moyen raisonnable de le faire pour le moment
Paul S
4

Pour créer une signature, je dois utiliser ma clé secrète. Mais tout se passe du côté client, donc la clé secrète peut être facilement révélée à partir de la source de la page (même si j'obscurcit / crypte mes sources).

C'est là que vous avez mal compris. La raison même pour laquelle les signatures numériques sont utilisées est que vous pouvez vérifier que quelque chose est correct sans révéler votre clé secrète. Dans ce cas, la signature numérique est utilisée pour empêcher l'utilisateur de modifier la politique que vous avez définie pour la publication du formulaire.

Les signatures numériques telles que celle présentée ici sont utilisées pour la sécurité sur tout le Web. Si quelqu'un (NSA?) Était vraiment capable de les briser, il aurait des cibles beaucoup plus grandes que votre seau S3 :)

OlliM
la source
2
mais un robot peut essayer de télécharger rapidement des fichiers illimités. puis-je définir une politique de fichiers maximum par compartiment?
Dejell
3

J'ai donné un code simple pour télécharger des fichiers du navigateur Javascript vers AWS S3 et répertorier tous les fichiers du compartiment S3.

Pas:

  1. Pour savoir comment créer Create IdentityPoolId http://docs.aws.amazon.com/cognito/latest/developerguide/identity-pools.html

    1. Accédez à la page de console de S3 et ouvrez la configuration des cors à partir des propriétés du compartiment et écrivez le code XML suivant dans celle-ci.

      <?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. Créez un fichier HTML contenant le code suivant, modifiez les informations d'identification, ouvrez le fichier dans le navigateur et profitez-en.

      <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
la source
2
Est-ce que personne ne pourrait utiliser mon "IdentityPoolId" pour télécharger des fichiers dans mon compartiment S3. Comment cette solution empêche-t-elle une tierce partie de simplement copier mon «IdentityPoolId» et de télécharger de nombreux fichiers dans mon compartiment S3?
Sahil
1
stackoverflow.com/users/4535741/sahil Vous pouvez empêcher le téléchargement de données / fichiers à partir d'autres domaines en définissant les paramètres CORS appropriés sur le compartiment S3. Ainsi, même si quelqu'un a accédé à votre identifiant de pool d'identité, il ne peut pas manipuler vos fichiers de compartiment s3.
Nilesh Pawar le
2

Si vous n'avez pas de code côté serveur, votre sécurité dépend de la sécurité de l'accès à votre code JavaScript côté client (c'est-à-dire que tous ceux qui ont le code peuvent télécharger quelque chose).

Je recommanderais donc de créer simplement un compartiment S3 spécial qui est accessible en écriture public (mais non lisible), de sorte que vous n'avez pas besoin de composants signés côté client.

Le nom du bucket (un GUID par exemple) sera votre seule défense contre les téléchargements malveillants (mais un attaquant potentiel ne pourra pas utiliser votre bucket pour transférer des données, car il est écrit uniquement pour lui)

Ruediger Jungbeck
la source
1

Voici comment générer un document de politique à l'aide de nœuds et sans serveur

"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;

L'objet de configuration utilisé est stocké dans SSM Parameter Store et ressemble à ceci

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

Si vous souhaitez utiliser un service tiers, auth0.com prend en charge cette intégration. Le service auth0 échange une authentification de service SSO tiers contre un jeton de session temporaire AWS avec des autorisations limitées.

Voir: https://github.com/auth0-samples/auth0-s3-sample/
et la documentation auth0.

Jason
la source
1
Si je comprends bien, nous avons maintenant Cognito pour cela?
Vitaly Zdanevich