Skip to content

Instantly share code, notes, and snippets.

@perpil
Last active January 21, 2025 23:39
Show Gist options
  • Save perpil/9eef495eddc686db377f77b7836efc2a to your computer and use it in GitHub Desktop.
Save perpil/9eef495eddc686db377f77b7836efc2a to your computer and use it in GitHub Desktop.
Viewer request code for sigv4 signing in a cloudfront function to furl. This code calculates the signature, but it doesn work because the Authorization header is stripped by cloudfront. Use CloudFront Lambda OAC instead.
const crypto = require('crypto');
const querystring = require('querystring');
function hmac(key, string, encoding) {
return crypto
.createHmac('sha256', key)
.update(string, 'utf8')
.digest(encoding);
}
function hash(string, encoding) {
return crypto.createHash('sha256').update(string, 'utf8').digest(encoding);
}
// This function assumes the string has already been percent encoded
function encodeRfc3986(urlEncodedString) {
return urlEncodedString.replace(/[!'()*]/g, function (c) {
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
});
}
function encodeRfc3986Full(str) {
return encodeRfc3986(encodeURIComponent(str));
}
var HEADERS_TO_IGNORE = [
/*'authorization',
'connection',
'x-amzn-trace-id',
'user-agent',
'expect',
'presigned-expires',
'range',*/
];
// request: { path | body, [host], [method], [headers] }
// credentials: { accessKeyId, secretAccessKey, [sessionToken] }
let tRequest,tCredentials,tService,tRegion,tExtraHeadersToIgnore,tExtraHeadersToInclude,tParsedPath,tDatetime;
function RequestSigner(request, credentials) {
var headers = (request.headers = request.headers || {});
tRequest = request;
tCredentials = credentials;
tService = 'lambda';
tRegion = request.host.match(/^[^\.]+\.lambda-url\.(.*?)\.on\.aws$/)[1];
if (!request.method && request.body) request.method = 'POST';
if (!headers.Host && !headers.host) {
headers.Host = request.host;
}
tExtraHeadersToIgnore =
request.extraHeadersToIgnore || {};
tExtraHeadersToInclude =
request.extraHeadersToInclude || {};
}
function prepareRequest() {
parsePath();
var request = tRequest,
headers = request.headers,
query;
if (!request.doNotModifyHeaders) {
if (request.body && !headers['Content-Type'] && !headers['content-type'])
headers['Content-Type'] =
'application/x-www-form-urlencoded; charset=utf-8';
if (
request.body &&
!headers['Content-Length'] &&
!headers['content-length']
)
headers['Content-Length'] = Buffer.byteLength(request.body);
if (
tCredentials.sessionToken &&
!headers['X-Amz-Security-Token'] &&
!headers['x-amz-security-token']
)
headers['X-Amz-Security-Token'] = tCredentials.sessionToken;
headers['X-Amz-Date'] = getDateTime();
}
//delete headers.Authorization;
//delete headers.authorization;
}
function sign() {
if (tParsedPath) prepareRequest();
tRequest.headers.Authorization = authHeader();
tRequest.path = formatPath();
return tRequest;
}
function getDateTime() {
if (!tDatetime) {
var headers = tRequest.headers,
date = new Date(headers.Date || headers.date || new Date());
tDatetime = date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
}
return tDatetime;
}
function getDate() {
return getDateTime().substr(0, 8);
}
function authHeader() {
return `AWS4-HMAC-SHA256 Credential=${
tCredentials.accessKeyId
}/${credentialString()}, SignedHeaders=${signedHeaders()}, Signature=${signature()}`;
}
function signature() {
var date = getDate();
var kCredentials = hmac(
hmac(
hmac(hmac('AWS4' + tCredentials.secretAccessKey, date), tRegion),
tService
),
'aws4_request'
);
return hmac(kCredentials, stringToSign(), 'hex');
}
function stringToSign() {
return [
'AWS4-HMAC-SHA256',
getDateTime(),
credentialString(),
hash(canonicalString(), 'hex'),
].join('\n');
}
function canonicalString() {
if (!tParsedPath) prepareRequest();
var pathStr = tParsedPath.path,
query = tParsedPath.query,
headers = tRequest.headers,
queryStr = '',
decodePath = tRequest.doNotEncodePath,
bodyHash;
bodyHash =
headers['X-Amz-Content-Sha256'] ||
headers['x-amz-content-sha256'] ||
hash(tRequest.body || '', 'hex');
if (query) {
var reducedQuery = Object.keys(query).reduce(function (obj, key) {
if (!key) return obj;
obj[encodeRfc3986Full(key)] = !Array.isArray(query[key])
? query[key]
: query[key];
return obj;
}, {});
var encodedQueryPieces = [];
Object.keys(reducedQuery)
.sort()
.forEach(function (key) {
if (!Array.isArray(reducedQuery[key])) {
encodedQueryPieces.push(
key + '=' + encodeRfc3986Full(reducedQuery[key])
);
} else {
reducedQuery[key]
.map(encodeRfc3986Full)
.sort()
.forEach(function (val) {
encodedQueryPieces.push(key + '=' + val);
});
}
});
queryStr = encodedQueryPieces.join('&');
}
if (pathStr !== '/') {
pathStr = pathStr
.split('/')
.reduce(function (path, piece) {
if (decodePath) piece = decodeURIComponent(piece.replace(/\+/g, ' '));
path.push(encodeRfc3986Full(piece));
return path;
}, [])
.join('/');
if (pathStr[0] !== '/') pathStr = '/' + pathStr;
}
return [
tRequest.method || 'GET',
pathStr,
queryStr,
canonicalHeaders() + '\n',
signedHeaders(),
bodyHash,
].join('\n');
}
function canonicalHeaders() {
var headers = tRequest.headers;
function trimAll(header) {
return header.toString().trim().replace(/\s+/g, ' ');
}
return Object.keys(headers)
.filter(function (key) {
return !HEADERS_TO_IGNORE.includes(key.toLowerCase());
})
.sort(function (a, b) {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
})
.map(function (key) {
return key.toLowerCase() + ':' + trimAll(headers[key]);
})
.join('\n');
}
function signedHeaders() {
var extraHeadersToInclude = tExtraHeadersToInclude,
extraHeadersToIgnore = tExtraHeadersToIgnore;
return Object.keys(tRequest.headers)
.map(function (key) {
return key.toLowerCase();
})
.filter(function (key) {
return (
extraHeadersToInclude[key] ||
(!HEADERS_TO_IGNORE.includes(key) && !extraHeadersToIgnore[key])
);
})
.sort()
.join(';');
}
function credentialString() {
return [getDate(), tRegion, tService, 'aws4_request'].join('/');
}
function parsePath() {
var path = tRequest.path || '/';
if (/[^0-9A-Za-z;,/?:@&=+$\-_.!~*'()#%]/.test(path)) {
path = encodeURI(decodeURI(path));
}
var queryIx = path.indexOf('?'),
query = null;
if (queryIx >= 0) {
query = querystring.parse(path.slice(queryIx + 1));
path = path.slice(0, queryIx);
}
tParsedPath = {
path: path,
query: query,
};
}
function formatPath() {
var path = tParsedPath.path,
query = tParsedPath.query;
if (!query) return path;
// Services don't support empty query string keys
if (query[''] != null) delete query[''];
return path + '?' + encodeRfc3986(querystring.stringify(query));
}
function handler(event) {
const request = event.request;
const qs = request.querystring;
let qo = {};
Object.keys(qs).forEach(k => qo[k] = qs[k].value);
let q = querystring.stringify(qo)
let requestOptions = {
host: request.headers.host.value,
path: request.uri + q?`?${q}`:''
};
RequestSigner(requestOptions,{secretAccessKey: 'FIXME', accessKeyId: 'FIXME'});
sign();
request.headers['authorization'] = {value:requestOptions.headers.Authorization};
request.headers['x-amz-date'] = {value:requestOptions.headers['X-Amz-Date']};
return request;
}
@kevin-mitchell
Copy link

Thank you! I actually was overthinking this, I knew I had to set the hash of the payload in the header you mentioned but in my head I was thinking I had to SIGN the payload, i.e. get a secret / key / whatever and use it to sign the payload.

(I'm mainly posting this in case anybody similar to me finds this in the future)

It turns out that all you have to do, which again you already knew it sounds like, is hash the payload without any sort of secrets - I assume that the reason for this is that the infra in place that's doing the OAC signing stuff maybe doesn't have the actual body on hand to sign, or perhaps there is more to it, but I imagine (though don't know) it's "easier" for AWS to use this x-amz-content-sha256 head as a stand-in for actually looking at the request body and hashing it as part of the signing process.

Regardless of exactly how this works, using CDK I was able to get everything working without requiring client side (i.e. in browser) work by using a Lambda@Edge function to hash the content and stick it in the header. Looking at this now, for my use case it wouldn't actually be that bad to just do this on the actual client side, again originally I came to this thinking I would need to do something more complicated. There is a cost associated with Lambda@Edge so it's not ideal. That said, this is for a small personal project and I'm expecting, literally, a 10-20 requests a month here.

CDK

This code I copy / pasted from a different gist, which I didn't find until much after this one: https://gist.github.com/antonbabenko/f9eee9603a525d55c3ae1abba1a561f5 - this is using inline because i'm not concerned about changing the code and it's more about infra. I imagine aws_cloudfront.experimental.EdgeFunction is subject to change at some point.

    const edgeFunction = new aws_cloudfront.experimental.EdgeFunction(
      this,
      "lambda-edge-poc",
      {
        code: Code.fromInline(`
        "use strict";
        
        const crypto = require("crypto");
        
        exports.handler = (event, context, callback) => {
          const request = event.Records[0].cf.request;
          const headers = request.headers;
          const method = request.method;
          const body = Buffer.from(request.body.data, "base64").toString();
        
          if (method != "POST" && method != "PUT") {
            return callback(null, request);
          }
        
          const hash = crypto.createHash("sha256").update(body).digest("hex");
        
          if (!headers["x-amz-content-sha256"]) {
            headers["x-amz-content-sha256"] = [
              { key: "x-amz-content-sha256", value: hash },
            ];
          }
        
          return callback(null, request);
        };
        
        `),
        handler: "index.handler",
        runtime: Runtime.NODEJS_22_X,
      }
    );

Then inside of my distribution with the specific behavior I want this to sit in front of (note the edgeLambdas property is where the above EdgeFunction goes):

....
additionalBehaviors: {
          "config.json": {
            allowedMethods: AllowedMethods.ALLOW_ALL,
            origin: FunctionUrlOrigin.withOriginAccessControl(
              props.requestHandlers.apiHandlerFunctionURL
            ),
            cachedMethods: CachedMethods.CACHE_GET_HEAD,
            cachePolicy,
            originRequestPolicy:
              OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
            responseHeadersPolicy,
            edgeLambdas: [
              {
                includeBody: true,
                functionVersion: edgeFunction.currentVersion,
                eventType: LambdaEdgeEventType.VIEWER_REQUEST,
              },
            ],
          },
......

@perpil
Copy link
Author

perpil commented Jan 21, 2025

I've updated the description to note that it doesn't work so future readers don't get fooled.

Using an edge lambda is a valid way of calculating the x-amz-content-sha256 so you don't need to do it on the client. The use case where it makes sense is that you need a custom url for your furl or you need to add WAF. If you don't need either of those, you'll get lower latency by turning off IAM on your furl and using it directly. Without doing additional authentication in your edge lambda, the threat profile is similar to having a wide open lambda.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment