Skip to content

Instantly share code, notes, and snippets.

@perpil
Last active January 21, 2025 23:39
Show Gist options
  • Select an option

  • Save perpil/9eef495eddc686db377f77b7836efc2a to your computer and use it in GitHub Desktop.

Select an option

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

Thanks for this - any hints as to the best way to store {secretAccessKey: 'FIXME', accessKeyId: 'FIXME'} for CloudFront Functions? With Lambda I normally would store secrets in SSM as encrypted values and fetch them at runtime with a Lambda layer, but I don't think anything like this is possible with CF Functions...

Bonus question: does this run fast enough in your testing? My understanding is CloudFront Functions are supposed to be like.. 10ms or something? Wondering if you've had any issues with limits?

@perpil
Copy link
Author

perpil commented Jan 15, 2025

@kevin-mitchell To answer your questions:

  1. For the credentials, you could store them in cloudfront kvstore and scope down the permissions to just lambda invokefunctionurl on the function if you want to limit blast radius.
  2. It did run fast enough to sign get requests, but the Authorization header gets stripped by the time it hits Lambda so it doesn't work. Here's the thread on this gist

With that said, I wrote this before Lambda OAC support with CloudFront. Now that that exists, doing it yourself is no longer necessary (and actually works, unlike the above). If you're interested here's a couple of bonus posts about my adventures in CloudFront functions:

Using CloudFront Functions as a REST API
Using CloudFront functions as a lightweight proxy

@kevin-mitchell
Copy link

kevin-mitchell commented Jan 16, 2025

edit: I just re-read the titles of your posts at least and realize now you're specifically talking about the CloudFront Functions being used as an API - I have slower functionality that I'd like to run in Lambda ideally, not CloudFront Functions most likely, so that's the disconnect - I suppose the CloudFront Functions would work perfectly fine with POST and PUT data, my hangup is still on getting OAC to work with Lambda and POST / PUT body signing which as far as I can tell, does not work without you doing it manually?

@perpil wow this is gold, thank you! I will carefully read through these later tonight - again, thanks a ton! That said, you mentioned Lambda OAC support, but they say this in the link you provided:

If you use PUT or POST methods with your Lambda function URL, your users must include the payload hash value in the x-amz-content-sha256 header when sending the request to CloudFront. Lambda doesn't support unsigned payloads.

I assumed this meant "it's on you to figure out how to do this if you want to support POST / PUT payloads", which is how I ended up on this gist originally.

With the caveat out of the way that I haven't read all of your links yet, the reason I originally found this snippet is specifically because I am using what I thought was the AWS blessed mechanism for safely calling Lambda from CloudFront. If you'll forgive the CDK, I have something like (note specifically the use of FunctionUrlOrigin.withOriginAccessControl):

..rest of the Distribution definition...
additionalBehaviors: {
          "config/obscure.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,
          },
....

This just doesn't appear to work with PUT or POST requests with body.

@perpil
Copy link
Author

perpil commented Jan 16, 2025

Ah ok, so CloudFront functions don't have access to the payload so the hash can't be added in a CloudFront function. For POST/PUT requests, the manual work you need to do is calculate the sha256 hash of the payload from the client and set the x-amz-content-sha256 header to it.

@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