客户端浏览器直接上传文件至Amazon S3 - 私钥泄露

187
我正在使用JavaScript通过REST API直接从客户端上传文件到Amazon S3,没有任何服务器端代码。一切都有效,但有一件事让我担忧... 当我向Amazon S3 REST API发送请求时,我需要签署请求并将签名放入“Authentication”标头中。为了创建签名,我必须使用我的秘密密钥。但是所有的操作都在客户端上进行,所以,秘密密钥可以很容易地从页面源代码中暴露出来(即使我混淆/加密我的源代码)。我该如何处理这个问题?这是一个问题吗?也许我可以将特定私有密钥的使用限制仅限于来自特定CORS来源的REST API调用,并且仅限于PUT和POST方法,或者将密钥链接到S3和特定存储桶?也许还有其他身份验证方法吗?“无服务器”解决方案是理想的,但我可以考虑涉及一些服务器端处理,不包括将文件上传到我的服务器,然后再将其发送到S3。

8
非常简单:不要在客户端存储任何机密信息。您需要涉及服务器来对请求进行签名。 - Ray Nicholus
1
您还会发现,在服务器端对这些请求进行签名和base-64编码要容易得多。在这里涉及到服务器似乎并不过分。我可以理解不想将所有文件字节发送到服务器,然后再上传到S3,但是客户端签署请求的好处非常少,特别是因为在客户端(使用JavaScript)执行此操作可能会有一些挑战,并且潜在地很慢。 - Ray Nicholus
5
在2016年,由于无服务器架构变得相当流行,使用AWS Lambda的帮助,可以直接将文件上传到S3。请参考我对类似问题的回答:https://dev59.com/IVkT5IYBdhLWcg3wIsOw#40828683。 基本上,您需要一个Lambda函数作为API签署每个文件的可上传URL,然后您的客户端JavaScript只需在预先签名的URL上执行HTTP PUT操作。我编写了一个Vue组件来处理这样的内容,其中S3上传相关代码与库无关,请查看并获取灵感。 - KF Lin
另一个第三方工具,用于在任何S3存储桶中进行HTTP/S POST上传。JS3Upload纯HTML5: http://www.jfileupload.com/products/js3upload-html5/index.html - JFU
10个回答

238

我认为你想要的是使用POST进行基于浏览器的上传。

基本上,你需要服务器端代码来生成签名策略。一旦客户端拿到了签名策略,它就可以直接使用POST将数据上传到S3而不必通过你的服务器传输数据。

这里是官方文档链接:

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

示例代码:http://docs.aws.amazon.com/AmazonS3/latest/dev/HTTPPOSTExamples.html

签名策略会以以下形式出现在你的html表单中:

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

注意,FORM操作直接将文件发送到S3,而不是通过您的服务器。

每当您的用户想要上传文件时,您都会在服务器上创建POLICY和SIGNATURE。然后将页面返回给用户的浏览器。用户可以直接上传文件到S3,而无需通过您的服务器。

签署策略时,通常会使策略在几分钟后过期。这迫使您的用户在上传之前与服务器通信。如果需要,这让您监视和限制上传。

唯一传输到或从您的服务器的数据是已签名的URL。您的秘密密钥保留在服务器上。


20
请注意,此处使用的是 Signature v2,该版本即将被 v4 替代:http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-authentication-HTTPPOST.html - Jörn Berkefeld
9
请务必将 ${filename} 加入到键名中,对于上面的例子,应该是 user/eric/${filename} 而不是只有 user/eric。如果 user/eric 已经存在作为一个文件夹,上传会默默地失败(你甚至会被重定向到成功操作重定向页面),上传的内容也不会存在其中。刚刚我花了几个小时来调试,以为这是一个权限问题。 - Balint Erdi
1
@Trip 由于浏览器将文件发送到S3,您需要在Javascript中检测超时并自行发起重试。 - secretmike
嗨,谢谢,但我在火狐浏览器上遇到了问题。请求超时,您的套接字连接未在超时期间读取或写入。空闲连接将被关闭,并且文件无法上传到S3。您能否帮助我解决这个问题?谢谢。 - Muhammad Usama Mashkoor
但如果我必须上传一个大文件的多部分内容怎么办?在这种情况下,在获取STS临时令牌(服务器端策略)后,我只能在AWS JS SDK客户端上使用,在那里我再次被迫硬编码凭据(即使是IAM用户)。 - void
显示剩余10条评论

47
你可以使用AWS S3 Cognito 来实现此操作,请点击以下链接:http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/browser-examples.html#Amazon_S3。另外,可以尝试使用以下代码,并更改区域、身份池ID和您的存储桶名称。

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

Github

1
@usama,你能否在GitHub上打开这个问题,因为我不太清楚这个问题。 - Joomler
@Joomler 很抱歉回复晚了,我在 GitHub 上开了一个问题,请看一下。谢谢。 https://github.com/aws/aws-sdk-php/issues/1332 - Muhammad Usama Mashkoor
@Joomler,你能告诉我如何获取身份池 ID 吗? - Muhammad Usama Mashkoor
这难道不会让用户访问同一存储桶中其他用户上传的文件吗? - Ryan Shillington
1
这应该是正确的答案 @Olegas - Stefan
显示剩余4条评论

16
您说您想要一个“无服务器”的解决方案,但这意味着您无法将任何“自己”的代码放到循环中。(注意:一旦您将您的代码交给客户端,它现在就是“他们”的代码了。)锁定CORS并不能帮助:人们可以轻松编写非基于Web的工具(或基于Web的代理),添加正确的CORS标头来滥用您的系统。
最大的问题是您无法区分不同的用户。您无法允许一个用户列出/访问他的文件,但阻止其他人这样做。如果检测到滥用,除了更改密钥外,您无法采取任何措施。(攻击者可以再次获得密钥。)
您最好的选择是为您的JavaScript客户端创建一个具有密钥的“IAM用户”。仅向其提供对一个存储桶的写访问权限。(但最好不要启用ListBucket操作,这会使其对攻击者更有吸引力。)
如果您有一个服务器(即使只是一个月20美元的简单微实例),您可以在实时监视/防止滥用的同时在您的服务器上签署密钥。如果没有服务器,您能做的最好的事情是事后定期监视滥用行为。以下是我会做的:
1)定期轮换该IAM用户的密钥:每晚为该IAM用户生成一个新密钥,并替换最旧的密钥。由于有2个密钥,因此每个密钥将有效期为2天。
2)启用S3日志记录,并每小时下载日志。设置“上传过多”和“下载过多”的警报。您将想检查总文件大小和上传的文件数。您还将要监视全球总数以及每个IP地址的总数(具有较低的门槛)。
这些检查可以在“无服务器”上运行,因为您可以在桌面上运行它们。(即S3完成所有工作,这些进程只是为了通知您滥用您的S3存储桶,以便您不会在月底收到巨额AWS账单。)

4
伙计,Lambda 出现之前的事情我都快忘了,那时候一切都很复杂。 - Ryan Shillington

14

在已经接受的答案中添加更多信息,您可以参考我的博客查看使用AWS Signature版本4的代码运行版本。

这里是总结:

用户选择要上传的文件后,立即执行以下操作: 1. 调用Web服务器以启动生成所需参数的服务

  1. 在此服务中,调用AWS IAM服务以获取临时凭证

  2. 拥有凭证后,创建一个Bucket Policy(Base64编码字符串)。 然后使用临时秘密访问密钥对Bucket Policy进行签名以生成最终签名

  3. 将必要的参数发送回UI

  4. 一旦接收到此内容,请创建一个HTML表单对象,设置所需的参数并进行POST操作。

有关详细信息,请参见 https://wordpress1763.wordpress.com/2016/10/03/browser-based-upload-aws-signature-version-4/


5
我花了一整天的时间在JavaScript中尝试解决这个问题,而这个答案告诉我如何使用XMLhttprequest来实现。我很惊讶你被点踩了。OP要求JavaScript,却在推荐答案中得到了表单。真是让人头疼。感谢您的答案! - Paul S
顺便提一句,superagent存在严重的CORS问题,所以目前XMLHttpRequest似乎是唯一合理的方法。 - Paul S

4

我提供了一个简单的代码,用于从Javascript浏览器上传文件到AWS S3并列出S3存储桶中的所有文件。

步骤:

  1. To know how to create Create IdentityPoolId http://docs.aws.amazon.com/cognito/latest/developerguide/identity-pools.html

    1. Goto S3's console page and open cors configuration from bucket properties and write following XML code into that.

      <?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. Create HTML file containing following code change the credentials, open file in browser and enjoy.

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

2
任何人都可以使用我的“IdentityPoolId”上传文件到我的S3存储桶,这个解决方案如何防止第三方复制我的“IdentityPoolId”并上传大量文件到我的S3存储桶? - Sahil
1
您可以通过为S3存储桶设置适当的CORS设置来防止来自其他域的数据/文件上传。因此,即使有人访问了您的身份池ID,他们也无法操纵您的S3存储桶文件。 - Nilesh Pawar

4
为了创建签名,我必须使用我的私钥。但所有的事情都发生在客户端,所以无论我如何混淆/加密我的源代码,秘密密钥都很容易从页面源代码中被揭示。这是您的误解。数字签名的主要作用就是可以在不暴露您的私钥的情况下验证某些内容是否正确。在这种情况下,数字签名用于防止用户修改您为表单提交设置的策略。像这样的数字签名在整个网络上都被用于安全保障。如果有人(国家安全局?)真的能够破解它们,那么他们会有比您的S3存储桶更大的目标 :)

3
机器人可能会尝试快速上传无限数量的文件。我能设置每个存储桶的最大文件数政策吗? - Dejell

3
如果您没有任何服务器端代码,那么您的安全性取决于客户端JavaScript代码访问的安全性(即拥有代码的每个人都可以上传东西)。
因此,我建议简单地创建一个特殊的S3存储桶,该存储桶是公共可写的(但不可读),因此您不需要在客户端使用任何已签名的组件。
存储桶名称(例如GUID)将是您对恶意上传的唯一防御(但潜在攻击者无法使用您的存储桶传输数据,因为对他而言只能进行写操作)。

3

以下是使用Node和Serverless生成策略文档的方法。

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

使用的配置对象存储在 SSM Parameter Store 中,其形式如下。
{
    "bucket": "my-bucket-name",
    "region": "us-west-2",
    "bucketAcl": "private",
    "accessKey": "MY_ACCESS_KEY",
    "secretKey": "MY_SECRET_ACCESS_KEY",
}

0
如果您愿意使用第三方服务,auth0.com支持此集成。auth0服务将第三方SSO服务的身份验证交换为具有有限权限的AWS临时会话令牌。
请参阅: https://github.com/auth0-samples/auth0-s3-sample/ 以及auth0文档。

2
据我所知,现在我们有Cognito来实现这个功能了? - Vitaly Zdanevich

-2

我基于VueJS和Go创建了一个UI,用于上传二进制文件到AWS Secrets Manager https://github.com/ledongthuc/awssecretsmanagerui

这对于上传安全文件和更新文本数据非常有帮助。如果需要,您可以参考一下。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接