From 0e7366be9960d316f244c78522c60a48053a701f Mon Sep 17 00:00:00 2001 From: atom-andrew Date: Sat, 15 Apr 2023 08:06:42 -0700 Subject: [PATCH] Assume the role specified in AWS_ROLE_ARN if AWS_WEB_IDENTITY_TOKEN_FILE is missing Allow use of AssumeRole to assume the desired identify when web identity is not in use Also update some signature code to explicitly pass through the desired method instead of assuming it matches the request. Finally, make it more obvious in signature code that signed components are path rather than a full uri. --- common/etc/nginx/include/awscredentials.js | 4 +- common/etc/nginx/include/awssig2.js | 6 +- common/etc/nginx/include/awssig4.js | 16 +-- common/etc/nginx/include/s3gateway.js | 107 ++++++++++++++++----- test/unit/awssig2_test.js | 2 +- test/unit/awssig4_test.js | 2 +- 6 files changed, 97 insertions(+), 40 deletions(-) diff --git a/common/etc/nginx/include/awscredentials.js b/common/etc/nginx/include/awscredentials.js index 417a04d8..c473e0da 100644 --- a/common/etc/nginx/include/awscredentials.js +++ b/common/etc/nginx/include/awscredentials.js @@ -41,7 +41,7 @@ function sessionToken(r) { */ function readCredentials(r) { // TODO: Change the generic constants naming for multiple AWS services. - if ('S3_ACCESS_KEY_ID' in process.env && 'S3_SECRET_KEY' in process.env) { + if ('S3_ACCESS_KEY_ID' in process.env && 'S3_SECRET_KEY' in process.env && !('AWS_ROLE_ARN' in process.env)) { const sessionToken = 'S3_SESSION_TOKEN' in process.env ? process.env['S3_SESSION_TOKEN'] : null; return { @@ -132,7 +132,7 @@ function _credentialsTempFile() { function writeCredentials(r, credentials) { /* Do not bother writing credentials if we are running in a mode where we do not need instance credentials. */ - if (process.env['S3_ACCESS_KEY_ID'] && process.env['S3_SECRET_KEY']) { + if (process.env['S3_ACCESS_KEY_ID'] && process.env['S3_SECRET_KEY'] && !('AWS_ROLE_ARN' in process.env)) { return; } diff --git a/common/etc/nginx/include/awssig2.js b/common/etc/nginx/include/awssig2.js index 5630b212..8997e9c8 100644 --- a/common/etc/nginx/include/awssig2.js +++ b/common/etc/nginx/include/awssig2.js @@ -22,14 +22,14 @@ const mod_hmac = require('crypto'); * Create HTTP Authorization header for authenticating with an AWS compatible * v2 API. * - * @param r {Request} HTTP request object + * @param r {Request} HTTP request object (for logging only) + * @param method {string} The http method * @param uri {string} The URI-encoded version of the absolute path component URL to create a request * @param httpDate {string} RFC2616 timestamp used to sign the request * @param credentials {object} Credential object with AWS credentials in it (AccessKeyId, SecretAccessKey, SessionToken) * @returns {string} HTTP Authorization header value */ -function signatureV2(r, uri, httpDate, credentials) { - const method = r.method; +function signatureV2(r, method, uri, httpDate, credentials) { const hmac = mod_hmac.createHmac('sha1', credentials.secretAccessKey); const stringToSign = method + '\n\n\n' + httpDate + '\n' + uri; diff --git a/common/etc/nginx/include/awssig4.js b/common/etc/nginx/include/awssig4.js index 6206796c..4e1b9a50 100644 --- a/common/etc/nginx/include/awssig4.js +++ b/common/etc/nginx/include/awssig4.js @@ -35,21 +35,22 @@ const DEFAULT_SIGNED_HEADERS = 'host;x-amz-content-sha256;x-amz-date'; * Create HTTP Authorization header for authenticating with an AWS compatible * v4 API. * - * @param r {Request} HTTP request object + * @param r {Request} HTTP request object (for logging only) * @param timestamp {Date} timestamp associated with request (must fall within a skew) * @param region {string} API region associated with request * @param service {string} service code (for example, s3, lambda) - * @param uri {string} The URI-encoded version of the absolute path component URL to create a canonical request + * @param method {string} The method + * @param path {string} The URI-encoded version of the absolute path component of the URI to create a canonical request * @param queryParams {string} The URL-encoded query string parameters to create a canonical request * @param host {string} HTTP host header value * @param credentials {object} Credential object with AWS credentials in it (AccessKeyId, SecretAccessKey, SessionToken) * @returns {string} HTTP Authorization header value */ -function signatureV4(r, timestamp, region, service, uri, queryParams, host, credentials) { +function signatureV4(r, timestamp, region, service, method, path, queryParams, host, credentials) { const eightDigitDate = utils.getEightDigitDate(timestamp); const amzDatetime = utils.getAmzDatetime(timestamp, eightDigitDate); const canonicalRequest = _buildCanonicalRequest( - r.method, uri, queryParams, host, amzDatetime, credentials.sessionToken); + method, path, queryParams, host, amzDatetime, credentials.sessionToken); const signature = _buildSignatureV4(r, amzDatetime, eightDigitDate, credentials, region, service, canonicalRequest); const authHeader = 'AWS4-HMAC-SHA256 Credential=' @@ -66,14 +67,14 @@ function signatureV4(r, timestamp, region, service, uri, queryParams, host, cred * * @see {@link https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html | Creating a Canonical Request} * @param method {string} HTTP method - * @param uri {string} URI associated with request + * @param path {string} The URI-encoded version of the absolute path component of the URI * @param queryParams {string} query parameters associated with request * @param host {string} HTTP Host header value * @param amzDatetime {string} ISO8601 timestamp string to sign request with * @returns {string} string with concatenated request parameters * @private */ -function _buildCanonicalRequest(method, uri, queryParams, host, amzDatetime, sessionToken) { +function _buildCanonicalRequest(method, path, queryParams, host, amzDatetime, sessionToken) { let canonicalHeaders = 'host:' + host + '\n' + 'x-amz-content-sha256:' + EMPTY_PAYLOAD_HASH + '\n' + 'x-amz-date:' + amzDatetime + '\n'; @@ -83,7 +84,7 @@ function _buildCanonicalRequest(method, uri, queryParams, host, amzDatetime, ses } let canonicalRequest = method + '\n'; - canonicalRequest += uri + '\n'; + canonicalRequest += path + '\n'; canonicalRequest += queryParams + '\n'; canonicalRequest += canonicalHeaders + '\n'; canonicalRequest += _signedHeaders(sessionToken) + '\n'; @@ -253,6 +254,7 @@ function _splitCachedValues(cached) { export default { signatureV4, + EMPTY_PAYLOAD_HASH, // These functions do not need to be exposed, but they are exposed so that // unit tests can run against them. _buildCanonicalRequest, diff --git a/common/etc/nginx/include/s3gateway.js b/common/etc/nginx/include/s3gateway.js index 6d694749..ea193d13 100644 --- a/common/etc/nginx/include/s3gateway.js +++ b/common/etc/nginx/include/s3gateway.js @@ -177,12 +177,12 @@ function s3auth(r) { const credentials = awscred.readCredentials(r); if (sigver == '2') { - let req = _s3ReqParamsForSigV2(r, bucket); - signature = awssig2.signatureV2(r, req.uri, req.httpDate, credentials); + const req = _s3ReqParamsForSigV2(r, bucket); + signature = awssig2.signatureV2(r, r.method, req.path, req.httpDate, credentials); } else { - let req = _s3ReqParamsForSigV4(r, bucket, server); + const req = _s3ReqParamsForSigV4(r, bucket, server); signature = awssig4.signatureV4(r, NOW, region, SERVICE, - req.uri, req.queryParams, req.host, credentials); + r.method, req.uri, req.queryParams, req.host, credentials); } return signature; @@ -194,7 +194,7 @@ function s3auth(r) { * @see {@link https://docs.aws.amazon.com/AmazonS3/latest/userguide/auth-request-sig-v2.html | AWS signature version 2} * @param r {Request} HTTP request object * @param bucket {string} S3 bucket associated with request - * @returns s3ReqParams {object} s3ReqParams object (host, method, uri, queryParams) + * @returns s3ReqParams {object} s3ReqParams object (host, method, path, queryParams) * @private */ function _s3ReqParamsForSigV2(r, bucket) { @@ -203,14 +203,14 @@ function _s3ReqParamsForSigV2(r, bucket) { * string to sign. For example, if we are requesting /bucket/dir1/ from * nginx, then in S3 we need to request /?delimiter=/&prefix=dir1/ * Thus, we can't put the path /dir1/ in the string to sign. */ - let uri = _isDirectory(r.variables.uri_path) ? '/' : r.variables.uri_path; + let path = _isDirectory(r.variables.uri_path) ? '/' : r.variables.uri_path; // To return index pages + index.html if (PROVIDE_INDEX_PAGE && _isDirectory(r.variables.uri_path)){ - uri = r.variables.uri_path + INDEX_PAGE + path = r.variables.uri_path + INDEX_PAGE } return { - uri: '/' + bucket + uri, + path: '/' + bucket + path, httpDate: s3date(r) }; } @@ -222,7 +222,7 @@ function _s3ReqParamsForSigV2(r, bucket) { * @param r {Request} HTTP request object * @param bucket {string} S3 bucket associated with request * @param server {string} S3 host associated with request - * @returns s3ReqParams {object} s3ReqParams object (host, uri, queryParams) + * @returns s3ReqParams {object} s3ReqParams object (host, path, queryParams) * @private */ function _s3ReqParamsForSigV4(r, bucket, server) { @@ -232,19 +232,19 @@ function _s3ReqParamsForSigV4(r, bucket, server) { } const baseUri = s3BaseUri(r); const queryParams = _s3DirQueryParams(r.variables.uri_path, r.method); - let uri; + let path; if (queryParams.length > 0) { if (baseUri.length > 0) { - uri = baseUri; + path = baseUri; } else { - uri = '/'; + path = '/'; } } else { - uri = s3uri(r); + path = s3uri(r); } return { host: host, - uri: uri, + path: path, queryParams: queryParams }; } @@ -509,7 +509,7 @@ const maxValidityOffsetMs = 4.5 * 60 * 1000; async function fetchCredentials(r) { /* If we are not using an AWS instance profile to set our credentials we exit quickly and don't write a credentials file. */ - if (utils.areAllEnvVarsSet(['S3_ACCESS_KEY_ID', 'S3_SECRET_KEY'])) { + if (utils.areAllEnvVarsSet('S3_ACCESS_KEY_ID', 'S3_SECRET_KEY') && !utils.areAllEnvVarsSet('AWS_ROLE_ARN')) { r.return(200); return; } @@ -558,6 +558,15 @@ async function fetchCredentials(r) { r.return(500); return; } + } + else if(utils.areAllEnvVarsSet('AWS_ROLE_ARN')) { + try { + credentials = await _fetchAssumeRoleCredentials(r); + } catch(e) { + utils.debug_log(r, `Could not assume role ${process.env['AWS_ROLE_ARN']}: ` + JSON.stringify(e)); + r.return(500); + return; + } } else { try { credentials = await _fetchEC2RoleCredentials(); @@ -643,17 +652,7 @@ async function _fetchEC2RoleCredentials() { }; } -/** - * Get the credentials by assuming calling AssumeRoleWithWebIdentity with the environment variable - * values ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE and HOSTNAME - * - * @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>} - * @private - */ -async function _fetchWebIdentityCredentials(r) { - const arn = process.env['AWS_ROLE_ARN']; - const name = process.env['HOSTNAME'] || 'nginx-s3-gateway'; - +function _getStsEndpoint() { let sts_endpoint = process.env['STS_ENDPOINT']; if (!sts_endpoint) { /* On EKS, the ServiceAccount can be annotated with @@ -679,6 +678,20 @@ async function _fetchWebIdentityCredentials(r) { sts_endpoint = 'https://sts.amazonaws.com'; } } + return sts_endpoint; +} + +/** + * Get the credentials by assuming calling AssumeRoleWithWebIdentity with the environment variable + * values ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE and HOSTNAME + * + * @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>} + * @private + */ +async function _fetchWebIdentityCredentials(r) { + const arn = process.env['AWS_ROLE_ARN']; + const name = process.env['HOSTNAME'] || 'nginx-s3-gateway'; + const sts_endpoint = _getStsEndpoint(); const token = fs.readFileSync(process.env['AWS_WEB_IDENTITY_TOKEN_FILE']); @@ -702,6 +715,48 @@ async function _fetchWebIdentityCredentials(r) { }; } +/** + * Get the credentials by assuming calling AssumeRole with the environment variable + * values AWS_ROLE_ARN, SECRET_ACCESS_KEY and ACCESS_KEY_ID + * + * @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>} + * @private + */ +async function _fetchAssumeRoleCredentials(r) { + const tempCreds = { + accessKeyId: process.env['S3_ACCESS_KEY_ID'], + secretAccessKey: process.env['S3_SECRET_KEY'], + }; + const arn = process.env['AWS_ROLE_ARN']; + const name = process.env['HOSTNAME'] || 'nginx-s3-gateway'; + const params = `Action=AssumeRole&RoleArn=${encodeURIComponent(arn)}&RoleSessionName=${encodeURIComponent(name)}&Version=2011-06-15`; + const sts_endpoint = _getStsEndpoint(); + const host = sts_endpoint.slice(8); + const method = 'GET'; + const region = process.env['AWS_REGION']; + const signature = awssig4.signatureV4(r, NOW, region, 'sts', method, '/', params, host, tempCreds); + const url = sts_endpoint + "?" + params; + const response = await ngx.fetch(url, { + headers: { + "Authorization": signature, + "X-Amz-Date": amzDatetime, + 'X-Amz-Content-Sha256': awssig4.EMPTY_PAYLOAD_HASH, + "Accept": "application/json" + }, + method: method, + }); + + const resp = await response.json(); + const creds = resp.AssumeRoleResponse.AssumeRoleResult.Credentials; + + return { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: creds.Expiration, + }; +} + export default { awsHeaderDate, fetchCredentials, diff --git a/test/unit/awssig2_test.js b/test/unit/awssig2_test.js index 67b69080..ee900509 100644 --- a/test/unit/awssig2_test.js +++ b/test/unit/awssig2_test.js @@ -37,7 +37,7 @@ function _runSignatureV2(r) { const httpDate = timestamp.toUTCString(); const expected = 'AWS test-access-key-1:VviSS4cFhUC6eoB4CYqtRawzDrc='; let req = s3gateway._s3ReqParamsForSigV2(r, bucket); - let signature = awssig2.signatureV2(r, req.uri, httpDate, creds); + let signature = awssig2.signatureV2(r, 'GET', req.path, httpDate, creds); if (signature !== expected) { throw 'V2 signature hash was not created correctly.\n' + diff --git a/test/unit/awssig4_test.js b/test/unit/awssig4_test.js index 0c1677a1..7424cc37 100644 --- a/test/unit/awssig4_test.js +++ b/test/unit/awssig4_test.js @@ -71,7 +71,7 @@ function _runSignatureV4(r) { // awssig4.js for the purpose of common library. let req = s3gateway._s3ReqParamsForSigV4(r, bucket, server); const canonicalRequest = awssig4._buildCanonicalRequest( - r.method, req.uri, req.queryParams, req.host, amzDatetime, creds.sessionToken); + r.method, req.path, req.queryParams, req.host, amzDatetime, creds.sessionToken); var expected = 'cf4dd9e1d28c74e2284f938011efc8230d0c20704f56f67e4a3bfc2212026bec'; var signature = awssig4._buildSignatureV4(