Protecting Single Page Applications from Web Skimming on Amazon CloudFront

In 2018, web Skimming led to British Airways inadvertently serving 496,636 customers credit cards to Magecart, which lead to a fine of ~$25,480,000 from the Information Commissioner's Office. Like physical card skimming, customers and merchants are caught unaware that sensitive details are being recorded and later received by a malicious party. Web skimming occurs when a threat actor injects their own application code into a merchant's website and they exfiltrate credentials, credit card numbers, and other personal information.

A card skimmer is pulled away from an ATM

Since the Magecart attack, the Payment Card Industry (PCI) has expedited its interest in merchants defending against the tactics used to skim credit cards, credentials, and personal information. For web applications, Content security policy (CSP) and subresource integrity (SRI) are vital tools to meet and defend against similar attacks.

In the interest of backwards compatibility, browsers have very permissive security policies by default. Web pages may freely include bootstrap CSS and JavaScript from a public Content Delivery Network (CDN), which the default security policy will allow. To replace this security policy, a content security policy may be specified with new constraints that enumerate trusted origins, resources, etc.

Threat actors may also attack by injecting themselves through resources in the supply-chain for a web application at run-time. For example, hackers injected cryptocurrency mining malware into 4,275 government websites through an automated translation plugin. This could have been prevented by using subresource integrity (SRI). Any scripts that are pulled from local or remote origins must have the same cryptographic hash that is specified by the origin serving the initial request.

Between 14 August 2018 and 25 August 2018, the Attacker [redacted] to redirect customer payment card data to a different website: "BAways.com". BAways.com was a site owned and controlled by the Attacker. It appears from BA's Second Representations that [redacted] had the effect of copying and redirecting payment card data to "BAways.com" (which BA refers to as "skimming"). [Redacted] remained active on BA's website for a period of 15 days between 21 August 2018 and 5 September 2018. During this time, when customers entered payment card information into BA's website, a copy was sent to the Attacker, without interrupting the normal BA booking and payment procedure. — British Airways Penalty Notice

To reduce repeats of web skimming attacks, PCI has released a new standard requiring the following:

  • § 6.4.3 All payment page scripts that are loaded and executed in the consumer's browser are managed as follows:
    • A method is implemented to confirm that each script is authorized.
    • A method is implemented to assure the integrity of each script.
    • An inventory of all scripts is maintained with written justification as to why each is necessary.
  • § 11.6.1 A change- and tamper-detection mechanism is deployed as follows:
    • To alert personnel to unauthorized modification (including indicators of compromise, changes, additions, and deletions) to the HTTP headers and the contents of payment pages as received by the consumer browser.
    • The mechanism is configured to evaluate the received HTTP header and payment page.

While PCI Data Security Standard (DSS) v4.0 does recommend CSP and SRI explicitly in § 6.4.3 and § 11.6.1, and the contents of this article are intended to be actionable recommendations for a specific use case, I am not your Qualified Security Assessor (QSA). You are responsible for complying with PCI for your business and working with a QSA to validate your compliance.

Furthermore, this article does not reflect the opinions or recommendations of any past, present, or future employer.


New PCI DSS requirements reduce risk to customers, merchants, and financial institutions. While these requirements are targeted to the payment page, they are also a best practice around authentication, authorization, and entry of personal and sensitive information.

Below, I describe the approach I am using to meet several (but not all) aspects of the requirements described above for a Single Page Application (SPA) deployed as a static website on Amazon CloudFront.

Single Page Applications on Amazon S3 and CloudFront

A typical pattern for deploying static websites is to drop assets on an Amazon S3 Bucket, set up an Amazon CloudFront distribution with the bucket as an origin, configure the origin default path and error response to return "/index.html", and point a DNS record to the distribution. As a result, customers see a first-party url over HTTPS, and any requests that don't go to a file are resolved to the single page application. Deployment is easy too. A CI/CD pipeline can build an application, sync the build folder to the S3 bucket and create an invalidation for "/index.html" on the distribution. In a few moments, the updated application is live!

A diagram showing a client connecting to cloudfront which requests a resource from Amazon S3

With CloudFront and S3, your surface area for attack is shifted to your supply-chain, build process, and any extra scripts you embed on your SPA.

You can also configure a Content Security Policy (CSP) to reply with a header such as:

Content-Security-Policy:
  default-src 'none';
  img-src 'self' https://*.stripe.com data:;
  media-src 'self' data:;
  font-src 'self';
  style-src 'self';
  script-src 'self';
  frame-ancestors 'none';
  frame-src https://js.stripe.com;
  connect-src https://api.example.com https://api.stripe.com;
  upgrade-insecure-requests;
  base-uri 'self';

This could be set using AWS Cloud Development Kit (CDK), like so:

const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "respPolicy", {
  securityHeadersBehavior: {
    contentSecurityPolicy: {
      contentSecurityPolicy: "default-src 'none'; ...",
      override: true,
    },
  }
});
const dist = new cloudfront.Distribution(this, 'dist', {
  defaultBehavior: {
    // ...
    responseHeadersPolicy,
  },
  defaultRootObject: "index.html",
  // ...
  errorResponses: [
    {
      httpStatus: 404,
      responseHttpStatus: 200,
      responsePagePath: "/index.html",
      ttl: Duration.minutes(1)
    },
    {
      httpStatus: 403,
      responseHttpStatus: 200,
      responsePagePath: "/index.html",
      ttl: Duration.minutes(1)
    }
  ]
});

Issues with a plain and simple approach

At an initial glance, this policy clears several security concerns. If the HTML sent to the user had an remote origin without the origin being added to the policy, it would not load. The moment the application depends on a remote origin that serves scripts with JSONP, this CSP policy isn't all that secure. An attacker might find a way to call on a JSONP request to launch their own script, rendering this policy ineffective. This is especially true if a public CDN, such as cdnjs.com is allow-listed in the policy.

How do we ensure that only authorized scripts are permitted to execute, and only the scripts intended for this application release? For that, we use a combination of CSP nonces and subresource integrity (SRI) hashes.

A note for those in the U.K. "nonce" in this article refers to "number used once."

Nonces should change on every response sent to a client. It establishes a root of trust starting at the response headers descending to all elements on the page. <script> and <style> elements may be annotated with a nonce="..." attribute which authorizes their execution and application.

In the CSP header, the policies for script-src and style-src may be prefixed with nonce-${random data}. Then all cases where the nonce attribute should be applied, it can be set to look like <script nonce="${random data}" ….

Stylesheets served with a <link> tag do not receive a nonce attribute. Instead, their origin must be accounted for in the policy separately. Additionally, stylesheets and JavaScript should come with a subresource integrity attribute like integrity="...". The value that goes into this attribute should be generated as part of the build process. The SRI Hash Generator may be useful for any manually created files that are not expected to change.

Some scripts and stylesheets cannot come with an integrity attribute by design, as they are intended to change independently as needs evolve. One example would be the ReCaptcha script. These should be independently considered and assessed for its supply-chain risk.

While a build process like Angular may come with flags like --subresource-integrity, it will not affix nonces to elements that require it under a CSP with script-policy 'nonce-${random data}'. Even if nonces were added with a post-build step before syncing to S3, it would not meet the spirit of nonces which should be unique on every response.

How then can we modify the response to return a single page application with nonces applied?

Amazon CloudFront Triggers with Lambda@Edge

Using CloudFront and S3 comes with a few benefits: an impeccable attack surface, a low cost to serve traffic near your customers, and automatic caching of static content near your customers for speedy responses across 400 points of presence. However, to add any sort of dynamic responses, either the origin must serve dynamic content, use CloudFront Functions to choose another origin which serves dynamic content, or use Lambda@Edge to serve a response itself.

S3 normally does not have any dynamic behavior. This is changing with Object Lambdas, which this article does not cover. There would be little benefit to serving an application merely to render some HTML with nonces next to static requests. This leaves Lambda@Edge as an optimal option in serving responses to users with a nonce applied.

A diagram showing a client connecting to cloudfront which passes a request through an AWS Lambda which may forward the request to Amazon S3.

Lambda@Edge integrates with CloudFront at four trigger points between the viewer and the origin — the origin being S3. AWS documents which types to use for several use cases. In short, origin request is the event to use.

Lambda@Edge does not support environment variables. This presents a challenge as environment specific configuration must either be hard coded or provided through another way. As a work-around, CloudFront distributions can set custom headers which are sent to the origin. While S3 will ignore unknown headers, a Lambda can read them from the origin request trigger event. With CDK, resource names, DNS names, and more can be injected through custom headers.

Unfortunately, another reason to use the origin request and not the origin response trigger is that CloudFront will not allow rewriting the response body on the origin response or viewer response. As a consequence, responses given by the lambda cannot benefit from the regional and edge caching that CloudFront infrastructure provides.

I prefer Cloudflare workers for this reason. Their tech allows rewriting response bodies from the origin and from edge caches. And, there are no confusing trigger points to choose from.

Adding nonces with Lambda@Edge

The Lambda will have to pass through all static requests for CloudFront to cache them. Application routes, including /, will need to return an early response. In doing so, it must fetch and cache the application's /index.html and rewrite the HTML with nonce="..." attributes. It should then respond with the rewritten HTML, add a CSP header with the trusted nonce, and send headers that prevent CloudFront from caching the response.

Additionally, it would be good if a CSP report URI were added. Clients will submit violations to the CSP report endpoint, alerting should inform a bad deploy that needs reverting.

If you are using a framework like Angular, and plan to use components that generate style tags at runtime, you may need to add the nonce value as an attribute. This is detailed in Angular Security - Content security policy. Without that, styles that are dynamically inserted may not work.

The response might then look like

<!doctype html>
<html lang="en">
<head>
  <title>Title</title>
  <link rel="stylesheet" href="styles-….css" crossorigin="anonymous" integrity="...">
</head>
<body>
  <app-root ngCspNonce="EXAMPLE-AAA"></app-root>
  <script nonce="EXAMPLE-AAA" src="main-….js" type="module" crossorigin="anonymous" integrity="..."></script>
</body>
</html>

And the response headers would look like

Content-Security-Policy:
  default-src 'none';
  img-src 'self' https://*.stripe.com data:;
  media-src 'self' data:;
  font-src 'self';
  form-action 'none';
  style-src 'self' 'nonce-EXAMPLE-AAA';
  script-src 'strict-dynamic' 'nonce-EXAMPLE-AAA' 'unsafe-inline' https:;
  frame-ancestors 'none';
  frame-src https://js.stripe.com;
  connect-src https://api.example.com https://api.stripe.com;
  upgrade-insecure-requests;
  base-uri 'self';
  report-uri https://your-report-collector.example.com/;
  report-to csp-report;
Report-To:
  {"group":"csp-report","max_age":604800,"endpoints":[{"url":"https://your-report-collector.example.com/"}]}

Note that 'self' has been replaced with 'strict-dynamic' on the script-src policy, and 'nonce-...' has been added to both the script-src and style-src policies.

A large diagram showing the above instructions in an organized way. If a resource is static like an image, then it passes through directly. Otherwise, it fetches index.html from S3 and rewrites it. It may also cache index.html locally.

Example code

The following code snippets are abbreviated. They have been tested in an AWS environment. These scripts may not meet all expectations. It is up to you as the software developer to implement a solution that meets the needs of your environment and customers and verify and debug what you release.

An example implementation for the Lambda@Edge function is below. It assumes the application generates a root object index.html and uses Angular.

'use strict';
import { randomBytes } from "crypto";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";

/** @type{S3Client} */
let s3Client;

/** @type{{contents: string, etag: string, date: Date} | null} */
let CACHE = null;

/**
* Fetches and caches index.html
* @param {string} bucket
* @param {string} region
* @returns {string}
*/
async function getIndex(bucket, region) {
  /** @type{string} */
  let html;
  let pullS3 = true;
  let refresh = false;
  const etag = CACHE && CACHE.etag || null;
  try {
    if (CACHE) {
      if (CACHE.date < new Date()) {
        // A refresh is needed
        refresh = true;
      } else {
        pullS3 = false;
      }
    }
    if (!pullS3 && CACHE) {
      html = CACHE.contents;
    } else {
      if (!s3Client) {
        s3Client = new S3Client({ region: region });
      }
      const s3Object = await s3Client.send(
        new GetObjectCommand({
          Bucket: bucket,
          Key: 'index.html',
          IfNoneMatch: etag || undefined,
        })
      );
      html = await s3Object?.Body?.transformToString("UTF-8");
      // Don't check S3 for 1 minute at a time
      CACHE = {
        contents: html,
        etag: s3Object?.ETag,
        date: new Date(new Date().getTime() + 60_000)
      };
    }
  } catch (ex) {
    // S3ServiceException
    if (ex.$response?.statusCode === 304) {
      html = CACHE.contents;
      if (refresh && CACHE) {
        // Refresh the cache date
        CACHE.date = new Date(new Date().getTime() + 60_000);
      }
    } else {
      console.error("Could not fetch resource", ex);
      throw ex;
    }
  }
  return html;
}

/**
* Rewrites Content Security Policy with nonce
* @param {string | null} contentSecurityPolicy
* @param {string} nonce
* @returns {string}
*/
function rewriteCsp(contentSecurityPolicy, nonce) {
  if (!contentSecurityPolicy) {
    // Have a sane default if misconfigured
    contentSecurityPolicy =
      "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'";
  }
  try {
    // Disassemble the content security policy set up in CDK
    const policies = contentSecurityPolicy.split(';');
    for (let i = 0; i < policies.length; i++) {
      const policy = policies[i].trim();
      let key, value;
      if (policy.includes(' ')) {
        [key, value] = policies[i].trim().split(/ (.*)/s);
      } else {
        key = policy;
        value = '';
      }
      if (key == 'script-src') {
        // Remove 'self'
        value = value.replaceAll("'self'", '').trim();
        // 'unsafe-inline' is ignored if nonces are supported.
        value = `'strict-dynamic' 'nonce-${nonce}' ${value} 'unsafe-inline'`;
        policies[i] = `${key} ${value.trim()}`;
      } else if (key == 'style-src') {
        value = `'nonce-${nonce}' ${value}`
        policies[i] = `${key} ${value.trim()}`;
      }
    }
    contentSecurityPolicy = policies.join('; ');
    // Add back in the reassembled policy
  } catch (ex) {
    console.error("Could not rewrite content security policy", ex);
  }
  return contentSecurityPolicy;
}

export const handler = async (event) => {
  //Get contents of response
  const request = event.Records[0].cf.request;

  let rewrite = false;
  /** @type {string} */
  const path = request.uri;
  if (path.includes('.')) {
    const extension = path.split('.').pop()
    if (extension == 'html') {
      rewrite = true;
    }
  } else {
    rewrite = true;
  }

  let [bucket,,region] =
    request.origin.s3.domainName.split('.', 3);

  if (rewrite && bucket) {
    const nonce = randomBytes(16)
      .toString("base64")
      .replaceAll('=', '');
    let html = await getIndex(bucket, region);

    // We aren't giving the same response so we need to
    // rebuild the headers
    /** @type{{[header:string]: {key: string, value: string}[]}} */
    const newHeaders = {};

    /** @type{string} */
    let contentSecurityPolicy = null;
    if (request.origin.s3.customHeaders['x-csp']) {
      // Load injected CSP with additional information
      contentSecurityPolicy =
        request.origin.s3.customHeaders['x-csp'][0].value;
    }

    contentSecurityPolicy =
      rewriteCsp(contentSecurityPolicy, nonce);

    if (html) {
      html = html
        .replaceAll('<script', `<script nonce="${nonce}"`)
        .replaceAll('<style', `<style nonce="${nonce}"`)
        // This is specific to an angular app with a root element
        // of "app-root"
        .replaceAll('<app-root', `<app-root ngCspNonce="${nonce}"`);

      newHeaders['content-type'] =
        [{key: 'Content-Type', value: 'text/html'}];
      newHeaders['content-encoding'] =
        [{key: 'Content-Encoding', value: 'UTF-8'}];
      // Send private cache only
      newHeaders['cache-control'] = [{
        key: 'Cache-Control',
        value: 'must-understand, private, max-age=600'
      }];
      newHeaders['content-security-policy'] = [{
        key: 'Content-Security-Policy',
        value: contentSecurityPolicy
      }];
      // Set the response body with the nonce-ified html
      const response = {
        status: 200,
        statusDescription: 'OK',
        body: html,
        headers: newHeaders
      }
      return response;
    }
  }
  return request;
};

The CDK script is updated to be like the following, which is abbreviated:

let reportUri = 'https://your-report-collector.example.com/';
const policies : {[key: string]: string} = {
  'default-src': "'none'",
  'img-src': "'self' https://*.stripe.com data:",
  'media-src': "'self' data:",
  'font-src': "'self'",
  'form-action': "'none'",
  'style-src': "'self'", // Will be modified
  'script-src': "'self' 'unsafe-inline' https:", // Will be modified and 'self' removed
  'frame-ancestors': "'none'",
  'frame-src': 'https://js.stripe.com',
  'connect-src': 'https://api.stripe.com https://maps.googleapis.com',
  'upgrade-insecure-requests': '',
  'base-uri': "'self'",
  'report-uri': reportUri,
  'report-to': 'csp-report'
}
// Assemble base content security policy
let contentSecurityPolicy = '';
for (const key of Object.keys(policies)) {
  if (contentSecurityPolicy.length > 0) {
    contentSecurityPolicy += '; ';
  }
  contentSecurityPolicy += key;
  const value = policies[key];
  if (value && value.length > 0) {
    contentSecurityPolicy += ` ${value}`;
  }
}

const edgeFunction = new cloudfront.experimental.EdgeFunction(this, 'add-csp-func', {
  runtime: lambda.Runtime.NODEJS_LATEST,
  handler: 'index.handler',
  // The lambda code is in edge-lambda/index.js
  code: lambda.Code.fromAsset(path.join(process.cwd(), 'edge-lambda/')),
});

bucket.grantRead(edgeFunction);
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "respPolicy", {
  securityHeadersBehavior: {
    contentSecurityPolicy: {
      contentSecurityPolicy,
      // Now this is false, as the Lambda may produce its own
      override: false,
    },
  },
  customHeadersBehavior: {
    customHeaders: [
      {
        header: 'Report-To',
        value: JSON.stringify({
          group: 'csp-report',
          max_age: 604800,
          endpoints: [{
            url: reportUri
          }]
        }),
        override: true
      }
    ]
  }
});
const dist = new cloudfront.Distribution(this, 'dist', {
  defaultBehavior: {
    // ...
    responseHeadersPolicy,
    origin: new S3Origin(bucket, {
      // ...
      customHeaders: {
        // This informs the lambda what the base content security policy should be
        'x-csp': contentSecurityPolicy
      }
    }),
  },
  defaultRootObject: "index.html",
  // ...
  errorResponses: [
    {
      httpStatus: 404,
      responseHttpStatus: 200,
      responsePagePath: "/index.html",
      ttl: Duration.minutes(1)
    },
    {
      httpStatus: 403,
      responseHttpStatus: 200,
      responsePagePath: "/index.html",
      ttl: Duration.minutes(1)
    }
  ]
});

A few more things

A Qualified Security Assessor may check your score on Mozilla's Observatory. With a configuration like above, plus options configured in securityHeadersBehavior, you're sure to get a passing score.

Mozilla's Observatory giving a perfect score

Note: Mozilla's Observatory is limited to requesting only the home page, which is not guaranteed to have the same response headers as the payment page. Several large businesses, like Amazon, do not have passing scores on their home page or payment page. They may have other means to meet compliance requirements with their respective QSAs.

Once you have your site deployed, make sure to open the developer console and observe any logged violations and adjust as needed until it works — without adding 'unsafe-inline' or 'unsafe-eval'. Thoroughly test essential workflows to ensure all application behavior is maintained.

I also recommend adding something to the site that causes a violation and deploy it to a QA / Dev environment. Double check that your report-to URI captures logs for violations. Afterwards, revert the intentional violation.

Last thoughts

The efforts described above assist in fulfilling PCI requirements § 6.4.3 and § 11.6.1. Content security policy (CSP) provide a reliable mechanism to authorize scripts for execution and to alert when unauthorized modifications are detected. Since the CSP headers are added externally to the deployed scripts, a compromise of the source code has limited risk, as connections, images, media, scripts, styles, forms, and frames are constrained by policies declared and assembled outside of the single page application.

That said, other requirements are not handled by content security policies.

To fulfill the script integrity requirement, the build process must support subresource integrity and append integrity="..." attributes to relevant scripts and styles. Not all scripts, such as remote scripts like ReCaptcha, support integrity as remote services may update their scripts without notice.

An inventory of all scripts is a concern that must be manually handled by the joint efforts of the development team and whomever is responsible for owning PCI compliance in the organization. A bespoke mechanism to regularly scrape and compare to the inventory is advised, and alerts should issued on deviations from the inventory.

Another requirement that should be handled is regular monitoring of if the infrastructure (such as CloudFormation with CDK) serving the page is changed in a meaningful way. If policies were altered or removed, an alert should be issued and acted upon within a reasonable time frame. This may also be handled by a bespoke mechanism.

As a final disclaimer, I am sharing my ideas and approaches for solving the needs of PCI at my employer. I am not a qualified security assessor and I cannot vouch for the completeness of what is described above in fulfilling the needs at your organization.