elestrin
elestrin

Reputation: 151

Modify Cloudfront origin response with Lambda - read-only headers

I have a Cloudfront distribution with a single React site, which is hosting in S3. The origin is connected via REST api. To properly handle queries, I use custom error responses on status 403 and 404 to 200 and route them to root. The root object is index.html and everything seems to be fine.

Now I have a task to add to a distribution an another site, which should be accessible through a subdirectory. To do this I have to set a root object for a subdirectory and to catch 404 and 403 responses and transfer them to a root object. I've already set up origin and behaviour.

I tried to use theese manuals: example source but it seems that something went wrong

The first approach (CloudFrontSubdirectoryIndex) seems not working at all (the function is not invoked and no rewrite happens), so i tried CloudFront function and it seems to work fine.

The last step is to handle 404 and 403 responses. Here is the function from the manual:

'use strict';

const http = require('https');

const indexPage = 'index.html';

exports.handler = async (event, context, callback) => {
    const cf = event.Records[0].cf;
    const request = cf.request;
    const response = cf.response;
    const statusCode = response.status;
    
    // Only replace 403 and 404 requests typically received
    // when loading a page for a SPA that uses client-side routing
    const doReplace = request.method === 'GET'
                    && (statusCode == '403' || statusCode == '404');
    
    const result = doReplace 
        ? await generateResponseAndLog(cf, request, indexPage)
        : response;
        
    callback(null, result);
};

async function generateResponseAndLog(cf, request, indexPage){
    
    const domain = cf.config.distributionDomainName;
    const appPath = getAppPath(request.uri);
    const indexPath = `/${appPath}/${indexPage}`;
    
    const response = await generateResponse(domain, indexPath);
    
    console.log('response: ' + JSON.stringify(response));
    
    return response;
}

async function generateResponse(domain, path){
    try {
        // Load HTML index from the CloudFront cache
        const s3Response = await httpGet({ hostname: domain, path: path });

        const headers = s3Response.headers || 
            {
                'content-type': [{ value: 'text/html;charset=UTF-8' }]
            };
            
        return {
            status: '200',
            headers: wrapAndFilterHeaders(headers),
            body: s3Response.body
        };
    } catch (error) {
        return {
            status: '500',
            headers:{
                'content-type': [{ value: 'text/plain' }]
            },
            body: 'An error occurred loading the page'
        };
    }
}

function httpGet(params) {
    return new Promise((resolve, reject) => {
        http.get(params, (resp) => {
            console.log(`Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`);
            let result = {
                headers: resp.headers,
                body: ''
            };
            resp.on('data', (chunk) => { result.body += chunk; });
            resp.on('end', () => { resolve(result); });
        }).on('error', (err) => {
            console.log(`Couldn't fetch ${params.hostname}${params.path} : ${err.message}`);
            reject(err, null);
        });
    });
}

// Get the app path segment e.g. candidates.app, employers.client etc
function getAppPath(path){
    if(!path){
        return '';
    }
    
    if(path[0] === '/'){
        path = path.slice(1);
    }
    
    const segments = path.split('/');
    
    // will always have at least one segment (may be empty)
    return segments[0];
}

// Cloudfront requires header values to be wrapped in an array
function wrapAndFilterHeaders(headers){
    const allowedHeaders = [
        'content-type',
        'content-length',
        'last-modified',
        'date',
        'etag'
    ];
    
    const responseHeaders = {};
    
    if(!headers){
        return responseHeaders;
    }
    
    for(var propName in headers) {
        // only include allowed headers
        if(allowedHeaders.includes(propName.toLowerCase())){
            var header = headers[propName];
            
            if (Array.isArray(header)){
                // assume already 'wrapped' format
                responseHeaders[propName] = header;
            } else {
                // fix to required format
                responseHeaders[propName] = [{ value: header }];
            }    
        }
        
    }
    
    return responseHeaders;
}

When i try to implement this solution (attach the function to origin response) I get

The Lambda function result failed validation: The function tried to add, delete, or change a read-only header.

Here is a list of restricted headers, but I'm not modifying any of them. If I try not to attach any headers to a response at all, the message is the same. If I try to attach all headers, CloudFront says that i'm modifying a black-listed header. Objects in a bucket have only one customized Cache-Control: no-cache metadata.

It seemed to be a fast task, but I'm stuck for two days already. Any help will be appreciated.

UPD: I've searched the logs and found

ERROR Validation error: Lambda function result failed validation, the function tried to delete read-only header, headerName : Transfer-Encoding.

I'm a little bit confused. This header is not present in origin response, but CF is telling that I deleted it...

Upvotes: 1

Views: 2330

Answers (1)

elestrin
elestrin

Reputation: 151

I tried to find the value of the header "Transfer-Encoding" that should come from origin (S3) but it seems that it has been disappeared. And CloudFront says that this header is essential.

So I've just hard-coded it and everything becomes fine.

'use strict';

const http = require('https');

const indexPage = 'index.html';

exports.handler = async (event, context, callback) => {
    const cf = event.Records[0].cf;
    const request = cf.request;
    const response = cf.response;
    const statusCode = response.status;
    
    // Only replace 403 and 404 requests typically received
    // when loading a page for a SPA that uses client-side routing
    const doReplace = request.method === 'GET'
                    && (statusCode == '403' || statusCode == '404');
    
    const result = doReplace 
        ? await generateResponseAndLog(cf, request, indexPage)
        : response;
        
    callback(null, result);
};

async function generateResponseAndLog(cf, request, indexPage){
    
    const domain = cf.config.distributionDomainName;
    const appPath = getAppPath(request.uri);
    const indexPath = `/${appPath}/${indexPage}`;
    
    const response = await generateResponse(domain, indexPath);
    
    console.log('response: ' + JSON.stringify(response));
    
    return response;
}

async function generateResponse(domain, path){
    try {
        // Load HTML index from the CloudFront cache
        const s3Response = await httpGet({ hostname: domain, path: path });

        const headers = s3Response.headers || 
            {
                'content-type': [{ value: 'text/html;charset=UTF-8' }]
            };
        s3Response.headers['transfer-encoding'] = 'chunked';    
        return {
            status: '200',
            headers: wrapAndFilterHeaders(headers),
            body: s3Response.body
        };
    } catch (error) {
        return {
            status: '500',
            headers:{
                'content-type': [{ value: 'text/plain' }]
            },
            body: 'An error occurred loading the page'
        };
    }
}

function httpGet(params) {
    return new Promise((resolve, reject) => {
        http.get(params, (resp) => {
            console.log(`Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`);
            let result = {
                headers: resp.headers,
                body: ''
            };
            resp.on('data', (chunk) => { result.body += chunk; });
            resp.on('end', () => { resolve(result); });
        }).on('error', (err) => {
            console.log(`Couldn't fetch ${params.hostname}${params.path} : ${err.message}`);
            reject(err, null);
        });
    });
}

// Get the app path segment e.g. candidates.app, employers.client etc
function getAppPath(path){
    if(!path){
        return '';
    }
    
    if(path[0] === '/'){
        path = path.slice(1);
    }
    
    const segments = path.split('/');
    
    // will always have at least one segment (may be empty)
    return segments[0];
}

// Cloudfront requires header values to be wrapped in an array
function wrapAndFilterHeaders(headers){
    const allowedHeaders = [
        'content-type',
        'content-length',
        'content-encoding',
        'transfer-encoding',
        'last-modified',
        'date',
        'etag'
    ];
    
    const responseHeaders = {};
    
    if(!headers){
        return responseHeaders;
    }
    
    for(var propName in headers) {
        // only include allowed headers
        if(allowedHeaders.includes(propName.toLowerCase())){
            var header = headers[propName];
            
            if (Array.isArray(header)){
                // assume already 'wrapped' format
                responseHeaders[propName] = header;
            } else {
                // fix to required format
                responseHeaders[propName] = [{ value: header }];
            }    
        }
        
    }
    
    return responseHeaders;
}

Upvotes: 2

Related Questions