Skip to content
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

feat: Enable CSP with nonce for Helix 5 #773

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion src/html-pipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export async function htmlPipe(state, req) {

if (state.content.sourceBus === 'code' || state.info.originalExtension === '.md') {
state.timer?.update('serialize');
await setCustomResponseHeaders(state, req, res);
await renderCode(state, req, res);
} else {
state.timer?.update('parse');
Expand All @@ -165,14 +166,14 @@ export async function htmlPipe(state, req) {
await createPictures(state);
await extractMetaData(state, req);
await addHeadingIds(state);
await setCustomResponseHeaders(state, req, res);
await render(state, req, res);
state.timer?.update('serialize');
await tohtml(state, req, res);
await applyMetaLastModified(state, res);
}

setLastModified(state, res);
await setCustomResponseHeaders(state, req, res);
await setXSurrogateKeyHeader(state, req, res);
} catch (e) {
res.error = e.message;
Expand Down
116 changes: 116 additions & 0 deletions src/steps/csp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import crypto from 'crypto';
import { select, selectAll } from 'hast-util-select';
import { remove } from 'unist-util-remove';

export const NONCE_AEM = '\'nonce-aem\'';

function parseCSP(csp) {
const parts = csp.split(';');
const result = {};
parts.forEach((part) => {
const [directive, ...values] = part.trim().split(' ');
result[directive] = values.join(' ');
});
return result;
}

function shouldApplyNonce(csp) {
const parsedCSP = parseCSP(csp);
return {
scriptNonce: parsedCSP['script-src']?.includes(NONCE_AEM),
styleNonce: parsedCSP['style-src']?.includes(NONCE_AEM),
};
}

function createAndApplyNonce(res, tree, metaCSP, headersCSP) {
const nonce = crypto.randomBytes(16).toString('base64');
andreituicu marked this conversation as resolved.
Show resolved Hide resolved
let scriptNonceResult = false;
let styleNonceResult = false;

if (metaCSP) {
const { scriptNonce, styleNonce } = shouldApplyNonce(metaCSP.properties.content);
scriptNonceResult ||= scriptNonce;
styleNonceResult ||= styleNonce;
metaCSP.properties.content = metaCSP.properties.content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`);
}

if (headersCSP) {
const { scriptNonce, styleNonce } = shouldApplyNonce(headersCSP);
scriptNonceResult ||= scriptNonce;
styleNonceResult ||= styleNonce;
res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
}

if (scriptNonceResult) {
selectAll('script[nonce="aem"]', tree).forEach((el) => {
el.properties.nonce = nonce;
});
}

if (styleNonceResult) {
selectAll('style[nonce="aem"]', tree).forEach((el) => {
el.properties.nonce = nonce;
});
selectAll('link[rel=stylesheet][nonce="aem"]', tree).forEach((el) => {
el.properties.nonce = nonce;
});
}
}

export function checkResponseBodyForMetaBasedCSP(res) {
return res.body?.includes('http-equiv="content-security-policy"')
|| res.body?.includes('http-equiv="Content-Security-Policy"');
}

export function checkResponseBodyForAEMNonce(res) {
return res.body?.includes(NONCE_AEM);
andreituicu marked this conversation as resolved.
Show resolved Hide resolved
}

export function getMetaCSP(tree) {
return select('meta[http-equiv="content-security-policy"]', tree)
|| select('meta[http-equiv="Content-Security-Policy"]', tree);
}

export function getHeaderCSP(res) {
return res.headers?.get('content-security-policy');
}

export function contentSecurityPolicy(res, tree) {
const metaCSP = getMetaCSP(tree);
const headersCSP = getHeaderCSP(res);

if (!metaCSP && !headersCSP) {
// No CSP defined
return;
}

// CSP with nonce
if (
(metaCSP && metaCSP.properties.content.includes(NONCE_AEM))
|| (headersCSP && headersCSP.includes(NONCE_AEM))
andreituicu marked this conversation as resolved.
Show resolved Hide resolved
) {
createAndApplyNonce(res, tree, metaCSP, headersCSP);
}

if (metaCSP && metaCSP.properties['move-as-header']) {
if (!headersCSP) {
// if we have a CSP in meta but no CSP in headers
// we can move the CSP from meta to headers, if requested
res.headers.set('content-security-policy', metaCSP.properties.content);
remove(tree, null, metaCSP);
} else {
delete metaCSP.properties['move-as-header'];
}
}
}
25 changes: 25 additions & 0 deletions src/steps/render-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
* governing permissions and limitations under the License.
*/
import mime from 'mime';
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import {
contentSecurityPolicy,
getHeaderCSP,
checkResponseBodyForMetaBasedCSP,
NONCE_AEM,
checkResponseBodyForAEMNonce,
} from './csp.js';
import tohtml from './stringify-response.js';

const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i;

Expand All @@ -32,4 +42,19 @@ export default async function renderCode(state, req, res) {
}
}
res.headers.set('content-type', contentType);

const cspHeader = getHeaderCSP(res);
if (state.type === 'html'
&& (cspHeader?.includes(NONCE_AEM) || (
checkResponseBodyForAEMNonce(res) && checkResponseBodyForMetaBasedCSP(res))
)
) {
res.document = await unified()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we really should rewrite the provided HTML. and if so, with unified which might alter the html. maybe using a very simple text based parser that only understands minimal HTML and repaces the nonce tokens would less intrusive.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used unified because that what was used in processing the head.html, which allowed me to keep the same codebase for processing both the document based and the static html and I like how it works with selectors.

Indeed, it does alter the resulting HTML, it makes it canonical (e.g. from the point of view of spaces, indentation, <SCRIPT> -> <script>, etc.).

very simple text based parser that only understands minimal HTML and repaces the nonce tokens would less intrusive.

Would you have any in mind that I could try out? Intuitively, I would expect that any parser when serialising back to canonicalise, since otherwise it would be very hard to store all the nuances of the original HTML.

If we want to keep the customer's HTML untouched except for the nonce generation, we could try a regex approach.

I usually avoid regexes for this kind of processing, because I'm not good at them, they become hard to maintain troubleshoot and I've seen Kodiak complain about ReDos, which means they could be slow for certain files.

Did a quick try with

res.body.replace(/(<script\b[^>]*\bnonce=")aem(")/ig, `$1${nonce}$2`)
      .replace(/(<style\b[^>]*\bnonce=")aem(")/ig, `$1${nonce}$2`)
      .replace(/(<link\b[^>]*\bnonce=")aem(")/ig, `$1${nonce}$2`)
      .replace(/(<meta\b[^>]*'nonce-)aem(')/ig, `$1${nonce}$2`)
      .replace(/(<meta\b[^>]*'nonce-)aem(')/ig, `$1${nonce}$2`) //can appear twice

I can't speak for all customers, but personally, as a developer, I think I would like that if I drop some messy HTML in github that I copied from somewhere I get a clean one when looking through the delivery service.

Copy link
Collaborator Author

@andreituicu andreituicu Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tripodsan WDYT of the latest approach from fdb2aeb?

I found a nice parser from parse5 which allows working with structured information for taking decisions, while still making it possible to make the final transformations directly on the raw html string, so we preserve (almost?) everything that the customer has (indentation, spacing, casing, etc.)

Additionally, if I understand correctly, the parse5 ecosystem sits at the lower level of rehype (not sure if this specific class that I used), which hopefully means that there should be no compatibility issues when running this JS in cloudflare.

Here is an example where it preserves everything and doesn't add anything extra: https://github.com/adobe/helix-html-pipeline/pull/773/files#diff-aced0b80c69cd5d57663548aa6ff82a7ab4f3ce37078a65eecc97ecc0c2cf4ba

Can now be tried with the following hlx-pipeline-version in helix 4.

  1. HTML from document: https://main--anvil--adobe-rnd.hlx.page/?hlx-pipeline-version=9.ci12807394100
  2. Static HTML: https://main--anvil--adobe-rnd.hlx.page/statichtml.html?hlx-pipeline-version=9.ci12807394100
  3. 404 Handler: https://main--anvil--adobe-rnd.hlx.page/notfound?hlx-pipeline-version=9.ci12807394100

.use(rehypeParse)
.parse(res.body);
res.body = undefined;

contentSecurityPolicy(res, res.document);
await tohtml(state, req, res);
}
}
2 changes: 2 additions & 0 deletions src/steps/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { h } from 'hastscript';
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
import { contentSecurityPolicy } from './csp.js';

function appendElement($parent, $el) {
if ($el) {
Expand Down Expand Up @@ -102,6 +103,7 @@ export default async function render(state, req, res) {
const $headHtml = await unified()
.use(rehypeParse, { fragment: true })
.parse(headHtml);
contentSecurityPolicy(res, $headHtml);
$head.children.push(...$headHtml.children);
}

Expand Down
37 changes: 37 additions & 0 deletions test/fixtures/code/super-test/static-nonce-header.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<html>
<head>
<title>ACME CORP</title>
<link rel="canonical" href="https://www.adobe.com/nonce-headers-meta">
<meta name="description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:title" content="ACME CORP">
<meta property="og:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:url" content="https://www.adobe.com/nonce-headers-meta">
<meta property="og:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta property="og:image:secure_url" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ACME CORP">
<meta name="twitter:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta name="twitter:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="locale" content="en-US">
<meta name="zero-cell" content="0">
<script nonce="aem" src="/scripts/aem.js" type="module"></script>
<script nonce="aem" src="/scripts/scripts.js" type="module"></script>
<link nonce="aem" rel="stylesheet" href="/styles/styles.css"/>
<script nonce="aem" > const a = 1 </script>
<style nonce="aem" id="at-body-style">body {opacity: 1}</style>
</head>
<body>
<header></header>
<main>
<div>
<h1 id="nonce-test">Nonce Test</h1>
<script nonce="aem" src="/scripts/aem2.js" type="module"></script>
<script nonce="aem" src="/scripts/scripts2.js" type="module"></script>
<link nonce="aem" rel="stylesheet" href="/styles/styles2.css"/>
<script nonce="aem" > const a = 2 </script>
<style nonce="aem" id="at-body-style2">body {opacity: 1}</style>
</div>
</main>
<footer></footer>
</body>
</html>
37 changes: 37 additions & 0 deletions test/fixtures/code/super-test/static-nonce-header.ref.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<html>
<head>
<title>ACME CORP</title>
<link rel="canonical" href="https://www.adobe.com/nonce-headers-meta">
<meta name="description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:title" content="ACME CORP">
<meta property="og:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:url" content="https://www.adobe.com/nonce-headers-meta">
<meta property="og:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta property="og:image:secure_url" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ACME CORP">
<meta name="twitter:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta name="twitter:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="locale" content="en-US">
<meta name="zero-cell" content="0">
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ==" src="/scripts/aem.js" type="module"></script>
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ==" src="/scripts/scripts.js" type="module"></script>
<link nonce="ckFuZDBtbW1yQW5kMG1tbQ==" rel="stylesheet" href="/styles/styles.css"/>
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ=="> const a = 1 </script>
<style nonce="ckFuZDBtbW1yQW5kMG1tbQ==" id="at-body-style">body {opacity: 1}</style>
</head>
<body>
<header></header>
<main>
<div>
<h1 id="nonce-test">Nonce Test</h1>
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ==" src="/scripts/aem2.js" type="module"></script>
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ==" src="/scripts/scripts2.js" type="module"></script>
<link nonce="ckFuZDBtbW1yQW5kMG1tbQ==" rel="stylesheet" href="/styles/styles2.css"/>
<script nonce="ckFuZDBtbW1yQW5kMG1tbQ=="> const a = 2 </script>
<style nonce="ckFuZDBtbW1yQW5kMG1tbQ==" id="at-body-style2">body {opacity: 1}</style>
</div>
</main>
<footer></footer>
</body>
</html>
38 changes: 38 additions & 0 deletions test/fixtures/code/super-test/static-nonce-meta-different.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-r4nD0m' 'strict-dynamic'; style-src 'nonce-r4nD0m'; base-uri 'self'; object-src 'none';">
<title>ACME CORP</title>
<link rel="canonical" href="https://www.adobe.com/nonce-meta-different">
<meta name="description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:title" content="ACME CORP">
<meta property="og:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:url" content="https://www.adobe.com/nonce-meta-different">
<meta property="og:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta property="og:image:secure_url" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ACME CORP">
<meta name="twitter:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta name="twitter:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="locale" content="en-US">
<meta name="zero-cell" content="0">
<script nonce="r4nD0m" src="/scripts/aem.js" type="module"></script>
<script nonce="r4nD0m" src="/scripts/scripts.js" type="module"></script>
<link nonce="r4nD0m" rel="stylesheet" href="/styles/styles.css"/>
<script nonce="r4nD0m" > const a = 1 </script>
<style nonce="r4nD0m" id="at-body-style">body {opacity: 1}</style>
</head>
<body>
<header></header>
<main>
<div>
<h1 id="nonce-test">Nonce Test</h1>
<script nonce="r4nD0m" src="/scripts/aem2.js" type="module"></script>
<script nonce="r4nD0m" src="/scripts/scripts2.js" type="module"></script>
<link nonce="r4nD0m" rel="stylesheet" href="/styles/styles2.css"/>
<script nonce="r4nD0m" > const a = 2 </script>
<style nonce="r4nD0m" id="at-body-style2">body {opacity: 1}</style>
</div>
</main>
<footer></footer>
</body>
</html>
38 changes: 38 additions & 0 deletions test/fixtures/code/super-test/static-nonce-meta-different.ref.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-r4nD0m' 'strict-dynamic'; style-src 'nonce-r4nD0m'; base-uri 'self'; object-src 'none';">
<title>ACME CORP</title>
<link rel="canonical" href="https://www.adobe.com/nonce-meta-different">
<meta name="description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:title" content="ACME CORP">
<meta property="og:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:url" content="https://www.adobe.com/nonce-meta-different">
<meta property="og:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta property="og:image:secure_url" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ACME CORP">
<meta name="twitter:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta name="twitter:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="locale" content="en-US">
<meta name="zero-cell" content="0">
<script nonce="r4nD0m" src="/scripts/aem.js" type="module"></script>
<script nonce="r4nD0m" src="/scripts/scripts.js" type="module"></script>
<link nonce="r4nD0m" rel="stylesheet" href="/styles/styles.css"/>
<script nonce="r4nD0m"> const a = 1 </script>
<style nonce="r4nD0m" id="at-body-style">body {opacity: 1}</style>
</head>
<body>
<header></header>
<main>
<div>
<h1 id="nonce-test">Nonce Test</h1>
<script nonce="r4nD0m" src="/scripts/aem2.js" type="module"></script>
<script nonce="r4nD0m" src="/scripts/scripts2.js" type="module"></script>
<link nonce="r4nD0m" rel="stylesheet" href="/styles/styles2.css"/>
<script nonce="r4nD0m"> const a = 2 </script>
<style nonce="r4nD0m" id="at-body-style2">body {opacity: 1}</style>
</div>
</main>
<footer></footer>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<html>
<head>
<meta http-equiv="content-security-policy" content="script-src 'nonce-aem' 'strict-dynamic'; style-src 'nonce-aem'; base-uri 'self'; object-src 'none';" move-as-header="true">
<title>ACME CORP</title>
<link rel="canonical" href="https://www.adobe.com/nonce-headers-meta">
<meta name="description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:title" content="ACME CORP">
<meta property="og:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta property="og:url" content="https://www.adobe.com/nonce-headers-meta">
<meta property="og:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta property="og:image:secure_url" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ACME CORP">
<meta name="twitter:description" content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tempor congue, nisi erat condimentum nunc, eget tincidunt nisl nunc euismod.">
<meta name="twitter:image" content="https://www.adobe.com/default-meta-image.png?width=1200&#x26;format=pjpg&#x26;optimize=medium">
<meta name="locale" content="en-US">
<meta name="zero-cell" content="0">
<script nonce="aem" src="/scripts/aem.js" type="module"></script>
<script nonce="aem" src="/scripts/scripts.js" type="module"></script>
<link nonce="aem" rel="stylesheet" href="/styles/styles.css"/>
<script nonce="aem" > const a = 1 </script>
<style nonce="aem" id="at-body-style">body {opacity: 1}</style>
</head>
<body>
<header></header>
<main>
<div>
<h1 id="nonce-test">Nonce Test</h1>
<script nonce="aem" src="/scripts/aem2.js" type="module"></script>
<script nonce="aem" src="/scripts/scripts2.js" type="module"></script>
<link nonce="aem" rel="stylesheet" href="/styles/styles2.css"/>
<script nonce="aem" > const a = 2 </script>
<style nonce="aem" id="at-body-style2">body {opacity: 1}</style>
</div>
</main>
<footer></footer>
</body>
</html>
Loading
Loading