Skip to content

Remove keyval / file based storage, replaced with ngx.shared. Additional x-amz-* headers were not signed #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@

- Initial release of the `nginx-aws-signature` repository.
- Compatible with [`nginx-s3-gateway`](https://github.com/nginxinc/nginx-s3-gateway) and [`nginx-lambda-gateway`](https://github.com/nginx-serverless/nginx-lambda-gateway).

## 1.1.0 (9 4, 2024)

- Removed keyval and filestore, replaced with ngx.shared
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ NGINX AWS Signature Library to authenticate AWS services such as S3 and Lambda v

This project is to provide the common library for your apps or services. To get this project up and running, the following nginx project can be used prior to implementing your project.

Requires njs 0.8.0+

- [Getting Started with `nginx-s3-gateway`](https://github.com/nginxinc/nginx-s3-gateway#getting-started)
- [Getting Started with `nginx-lambda-gateway`](https://github.com/nginx-serverless/nginx-lambda-gateway#getting-started)

Expand Down Expand Up @@ -96,6 +98,9 @@ map $request_uri $lambda_url {
default https://lambda.us-east-1.amazonaws.com;
}

#Create a shared dictionay 'aws' for ngx.shared
js_shared_dict_zone zone=aws:32k type=string;

server {
listen 80; # Use SSL/TLS in production

Expand Down
133 changes: 35 additions & 98 deletions core/awscredentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,79 +89,15 @@ function readCredentials(r) {
expiration: null
};
}
if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) {
return _readCredentialsFromKeyValStore(r);
} else {
return _readCredentialsFromFile();
}
}

/**
* Read credentials from the NGINX Keyval store. If it is not found, then
* return undefined.
*
* @param r {Request} HTTP request object (not used, but required for NGINX configuration)
* @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined
* @private
*/
function _readCredentialsFromKeyValStore(r) {
const cached = r.variables.instance_credential_json;

if (!cached) {
return undefined;
}

try {
return JSON.parse(cached);
} catch (e) {
utils.debug_log(r, `Error parsing JSON value from r.variables.instance_credential_json: ${e}`);
return undefined;
}
}

/**
* Read the contents of the credentials file into memory. If it is not
* found, then return undefined.
*
* @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined
* @private
*/
function _readCredentialsFromFile() {
const credsFilePath = _credentialsTempFile();

try {
const creds = fs.readFileSync(credsFilePath);
return JSON.parse(creds);
} catch (e) {
/* Do not throw an exception in the case of when the
credentials file path is invalid in order to signal to
the caller that such a file has not been created yet. */
if (e.code === 'ENOENT') {
return undefined;
}
throw e;
}
}

/**
* Returns the path to the credentials temporary cache file.
*
* @returns {string} path on the file system to credentials cache file
* @private
*/
function _credentialsTempFile() {
if (process.env['AWS_CREDENTIALS_TEMP_FILE']) {
return process.env['AWS_CREDENTIALS_TEMP_FILE'];
}
if (process.env['TMPDIR']) {
return `${process.env['TMPDIR']}/credentials.json`
if (ngx.shared.aws.has('instance_credential_json')) {
return JSON.parse(ngx.shared.aws.get('instance_credential_json'));
}

return '/tmp/credentials.json';
}

/**
* Write the instance profile credentials to a caching backend.
* Write the instance profile credentials to ngx.shared.aws
*
* @param r {Request} HTTP request object (not used, but required for NGINX configuration)
* @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials
Expand All @@ -177,35 +113,7 @@ function writeCredentials(r, credentials) {
throw `Cannot write invalid credentials: ${JSON.stringify(credentials)}`;
}

if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) {
_writeCredentialsToKeyValStore(r, credentials);
} else {
_writeCredentialsToFile(credentials);
}
}

/**
* Write the instance profile credentials to the NGINX Keyval store.
*
* @param r {Request} HTTP request object (not used, but required for NGINX configuration)
* @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials
* @private
*/
function _writeCredentialsToKeyValStore(r, credentials) {
r.variables.instance_credential_json = JSON.stringify(credentials);
}

/**
* Write the instance profile credentials to a file on the file system. This
* file will be quite small and should end up in the file cache relatively
* quickly if it is repeatedly read.
*
* @param r {Request} HTTP request object (not used, but required for NGINX configuration)
* @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials
* @private
*/
function _writeCredentialsToFile(credentials) {
fs.writeFileSync(_credentialsTempFile(), JSON.stringify(credentials));
ngx.shared.aws.set('instance_credential_json', JSON.stringify(credentials));
}

/**
Expand All @@ -223,12 +131,18 @@ function _writeCredentialsToFile(credentials) {
* quickly exits.
*
* @param r {Request} HTTP request object
* @returns {Promise<void>}
* @param inline {bool} If true, returns the result as a boolean, instead of invoking return on "r"
*
* @returns {Promise<void>|bool}
*/
async function fetchCredentials(r) {
async function fetchCredentials(r, inline) {

/* 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(['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'])) {
if (inline) {
return true;
}
r.return(200);
return;
}
Expand All @@ -239,6 +153,9 @@ async function fetchCredentials(r) {
current = readCredentials(r);
} catch (e) {
utils.debug_log(r, `Could not read credentials: ${e}`);
if (inline) {
return false;
}
r.return(500);
return;
}
Expand All @@ -249,6 +166,9 @@ async function fetchCredentials(r) {
const expireAt = typeof current.expiration == 'number' ? current.expiration * 1000 : current.expiration
const exp = new Date(expireAt).getTime() - maxValidityOffsetMs;
if (NOW.getTime() < exp) {
if (inline) {
return true;
}
r.return(200);
return;
}
Expand All @@ -265,6 +185,9 @@ async function fetchCredentials(r) {
credentials = await _fetchEcsRoleCredentials(uri);
} catch (e) {
utils.debug_log(r, 'Could not load ECS task role credentials: ' + JSON.stringify(e));
if (inline) {
return false;
}
r.return(500);
return;
}
Expand All @@ -274,6 +197,9 @@ async function fetchCredentials(r) {
credentials = await _fetchWebIdentityCredentials(r)
} catch (e) {
utils.debug_log(r, 'Could not assume role using web identity: ' + JSON.stringify(e));
if (inline) {
return false;
}
r.return(500);
return;
}
Expand All @@ -282,6 +208,9 @@ async function fetchCredentials(r) {
credentials = await _fetchEC2RoleCredentials();
} catch (e) {
utils.debug_log(r, 'Could not load EC2 task role credentials: ' + JSON.stringify(e));
if (inline) {
return false;
}
r.return(500);
return;
}
Expand All @@ -290,9 +219,17 @@ async function fetchCredentials(r) {
writeCredentials(r, credentials);
} catch (e) {
utils.debug_log(r, `Could not write credentials: ${e}`);
if (inline) {
return false;
}
r.return(500);
return;
}

if (inline) {
return true;
}

r.return(200);
}

Expand Down
61 changes: 49 additions & 12 deletions core/awssig4.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ const EMPTY_PAYLOAD_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495

/**
* Constant defining the headers being signed.
* @type {string}
* @type {array}
*/
const DEFAULT_SIGNED_HEADERS = 'host;x-amz-date';
const DEFAULT_SIGNED_HEADERS = ['host','x-amz-date'];

/**
* Create HTTP Authorization header for authenticating with an AWS compatible
Expand All @@ -54,7 +54,7 @@ function signatureV4(r, timestamp, region, service, uri, queryParams, host, cred
credentials, region, service, canonicalRequest);
const authHeader = 'AWS4-HMAC-SHA256 Credential='
.concat(credentials.accessKeyId, '/', eightDigitDate, '/', region, '/', service, '/aws4_request,',
'SignedHeaders=', _signedHeaders(r, credentials.sessionToken), ',Signature=', signature);
'SignedHeaders=', _signedHeaders(r, credentials.sessionToken).join(';'), ',Signature=', signature);

utils.debug_log(r, 'AWS v4 Auth header: [' + authHeader + ']');

Expand All @@ -76,18 +76,40 @@ function signatureV4(r, timestamp, region, service, uri, queryParams, host, cred
function _buildCanonicalRequest(r,
method, uri, queryParams, host, amzDatetime, sessionToken) {
const payloadHash = awsHeaderPayloadHash(r);
let canonicalHeaders = 'host:' + host + '\n' +
'x-amz-date:' + amzDatetime + '\n';

const canonicalHeaders = {
host: host,
'x-amz-date': amzDatetime
};

if (sessionToken && sessionToken.length > 0) {
canonicalHeaders += 'x-amz-security-token:' + sessionToken + '\n'
canonicalHeaders['x-amz-security-token'] = sessionToken;
}

//headers must be in alphabetical order
const signedHeaders = _signedHeaders(r, sessionToken).sort();

for (let i = 0; i < signedHeaders.length; i++) {
const header = signedHeaders[i];
if (canonicalHeaders[header] === undefined) {
canonicalHeaders[header] = r.headers.get(header);
}
}

const orderedCanonicalHeaderKeys = Object.keys(canonicalHeaders).sort();

let canonicalHeaderString = '';
for (let i = 0; i < orderedCanonicalHeaderKeys.length; i++) {
const header = orderedCanonicalHeaderKeys[i];

canonicalHeaderString += header + ':' + canonicalHeaders[header] + '\n';
}

let canonicalRequest = method + '\n';
canonicalRequest += uri + '\n';
canonicalRequest += queryParams + '\n';
canonicalRequest += canonicalHeaders + '\n';
canonicalRequest += _signedHeaders(r, sessionToken) + '\n';
canonicalRequest += canonicalHeaderString + '\n';
canonicalRequest += signedHeaders.join(';') + '\n';
canonicalRequest += payloadHash;
return canonicalRequest;
}
Expand Down Expand Up @@ -187,19 +209,34 @@ function _buildStringToSign(amzDatetime, eightDigitDate, region, service, canoni
}

/**
* Creates a string containing the headers that need to be signed as part of v4
* Returns an array of the headers that need to be signed as part of the v4
* signature authentication.
*
* @param r {Request} HTTP request object
* @param sessionToken {string|undefined} AWS session token if present
* @returns {string} semicolon delimited string of the headers needed for signing
* @returns {array}
* @private
*/
function _signedHeaders(r, sessionToken) {
let headers = DEFAULT_SIGNED_HEADERS;
let headers = [];

for (let i = 0; i < DEFAULT_SIGNED_HEADERS.length; i++) {
headers.push(DEFAULT_SIGNED_HEADERS[i]);
}

if (sessionToken && sessionToken.length > 0) {
headers += ';x-amz-security-token';
headers.push('x-amz-security-token');
}

//Any header that starts with x-amz
//must be included, eg x-amz-expected-bucket-owner
//https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
r.headers.forEach((k) => {
if (! headers.includes(k) && k.startsWith('x-amz-')) {
headers.push(k);
}
});

return headers;
}

Expand Down
4 changes: 4 additions & 0 deletions tests/docker/build_context/etc/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ http {

keepalive_timeout 65;

#Create a shared dictionay 'aws' for ngx.shared
js_shared_dict_zone zone=aws:32k type=string;

#gzip on;
include /etc/nginx/conf.d/*.conf;

}
4 changes: 2 additions & 2 deletions tests/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ services:
nginx_aws_signature_test:
hostname: nginx_aws_signature_test
container_name: nginx_aws_signature_test
image: nginx_aws_signature_test:${NGINX_TYPE}
image: nginx_aws_signature_test:${nginx_type}
build:
context: ./
dockerfile: Dockerfile.${NGINX_TYPE}
dockerfile: Dockerfile.${nginx_type}
volumes:
- ./build_context/etc/nginx/conf.d:/etc/nginx/conf.d
- ../../core:/etc/nginx/serverless
Expand Down
Loading