High | Path/Directory Traversal |
CWE-22
|
app.js:1601
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
1 Data Flow/s detected
|
const {vendor} = req.params; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
const file = await fs.promises.readFile(localPath); |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -0,0 +0,0 @@ |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
<iframe src="${req.url.substr(7)}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
@@ -1599,0 +1599,6 @@ |
|
+ const resolvedPath = path.resolve(localPath); |
|
+ const expectedDir = path.resolve(pc.cwd(), 'dist/v0/analytics-vendors'); |
|
+ if (!resolvedPath.startsWith(expectedDir)) { |
|
+ res.status(400).end('Invalid path'); |
|
+ return; |
|
+ } |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Path/Directory Traversal Training
● Videos
▪ Secure Code Warrior Path/Directory Traversal Video
● Further Reading
▪ OWASP Path Traversal
▪ OWASP Input Validation Cheat Sheet
|
|
High | Path/Directory Traversal |
CWE-22
|
app.js:1005
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
1 Data Flow/s detected
|
const filePath = req.path; |
|
.readFile(pc.cwd() + filePath, 'utf8') |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -0,0 +0,0 @@ |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
<iframe src="${req.url.substr(7)}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
@@ -1005,0 +1005,5 @@ |
|
+ const resolvedPath = path.resolve(pc.cwd(), filePath); |
|
+ const expectedDir = path.resolve(pc.cwd()); // TODO: Change this to your expected directory |
|
+ if (!resolvedPath.startsWith(expectedDir)) { |
|
+ throw new Error('Invalid path'); |
|
+ } |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Path/Directory Traversal Training
● Videos
▪ Secure Code Warrior Path/Directory Traversal Video
● Further Reading
▪ OWASP Path Traversal
▪ OWASP Input Validation Cheat Sheet
|
|
High | Path/Directory Traversal |
CWE-22
|
recaptcha-router.js:19
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
}; |
|
`; |
|
|
|
const recaptchaFrameRequestHandler = (req, res, next) => { |
|
if (argv._.includes('unit') || argv._.includes('integration')) { |
|
fs.promises.readFile(pc.cwd() + req.path, 'utf8').then((file) => { |
1 Data Flow/s detected
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
const recaptchaFrameRequestHandler = (req, res, next) => { |
|
fs.promises.readFile(pc.cwd() + req.path, 'utf8').then((file) => { |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const path = require('path') |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const cors = require('./amp-cors'); |
|
const pc = process; |
|
const fs = require('fs'); |
|
const multer = require('multer'); |
|
const recaptchaRouter = require('express').Router(); |
|
|
|
const upload = multer(); |
|
|
|
const recaptchaMock = ` |
|
window.grecaptcha = { |
|
ready: (callback) => callback(), |
|
execute: () => Promise.resolve('recaptcha-mock') |
|
}; |
|
`; |
|
|
|
const recaptchaFrameRequestHandler = (req, res, next) => { |
|
if (argv._.includes('unit') || argv._.includes('integration')) { |
|
@@ -19,1 +20,6 @@ |
|
- fs.promises.readFile(pc.cwd() + req.path, 'utf8').then((file) => { |
|
+ const resolvedPath = path.resolve(pc.cwd(), req.path); |
|
+ const expectedDir = path.resolve(pc.cwd()); // TODO: Change this to your expected directory |
|
+ if (!resolvedPath.startsWith(expectedDir)) { |
|
+ throw new Error('Invalid path'); |
|
+ } |
|
+ fs.promises.readFile(resolvedPath, 'utf8').then((file) => { |
|
file = file.replace( |
|
/initRecaptcha\(.*\)/g, |
|
'initRecaptcha("/recaptcha/mock.js?sitekey=")' |
|
); |
|
res.end(file); |
|
}); |
|
} else { |
|
next(); |
|
} |
|
}; |
|
|
|
recaptchaRouter.get('/mock.js', (_req, res) => { |
|
res.end(recaptchaMock); |
|
}); |
|
|
|
recaptchaRouter.post('/submit', upload.array(), (req, res) => { |
|
cors.enableCors(req, res); |
|
|
|
const responseJson = { |
|
message: 'Success!', |
|
}; |
|
|
|
Object.keys(req.body).forEach((bodyKey) => { |
|
responseJson[bodyKey] = req.body[bodyKey]; |
|
}); |
|
|
|
const containsRecaptchaInResponse = Object.keys(responseJson).some( |
|
(responseJsonKey) => { |
|
return responseJsonKey.toLowerCase().includes('recaptcha'); |
|
} |
|
); |
|
|
|
if (containsRecaptchaInResponse) { |
|
res.status(200).json(responseJson); |
|
} else { |
|
res.status(400).json({ |
|
message: 'Did not include a recaptcha token', |
|
}); |
|
} |
|
}); |
|
|
|
module.exports = { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
}; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Path/Directory Traversal Training
● Videos
▪ Secure Code Warrior Path/Directory Traversal Video
● Further Reading
▪ OWASP Path Traversal
▪ OWASP Input Validation Cheat Sheet
|
|
High | Cross-Site Scripting |
CWE-79
|
app.js:1355
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
1 Data Flow/s detected
|
app.get('/adzerk/*', (req, res) => { |
|
res.end('Invalid path: ' + req.path); |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
<iframe src="${req.url.substr(7)}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
@@ -1355,1 +1356,1 @@ |
|
- res.end('Invalid path: ' + req.path); |
|
+ res.end('Invalid path: ' + escape(req.path)); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
app.js:901
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
1 Data Flow/s detected
|
app.get('/iframe/*', (req, res) => { |
|
<iframe src="${req.url.substr(7)}" |
|
res.send(`<!doctype html> |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
@@ -904,1 +905,1 @@ |
|
- <iframe src="${req.url.substr(7)}" |
|
+ <iframe src="${escape(req.url.substr(7))}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
app.js:916
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
1 Data Flow/s detected
|
app.get('/a4a_template/*', (req, res) => { |
|
res.end('Invalid path: ' + req.path); |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+var escape = require('escape-html'); |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
<iframe src="${req.url.substr(7)}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
@@ -916,1 +917,1 @@ |
|
- res.end('Invalid path: ' + req.path); |
|
+ res.end('Invalid path: ' + escape(req.path)); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
app.js:942
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
|
res.send( |
1 Data Flow/s detected
|
app.get('/iframe-echo-message', (req, res) => { |
|
const {message} = req.query; |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
/** |
|
* @fileoverview Creates an http server to handle static |
|
* files and list directories for use with the amp live server |
|
*/ |
|
const argv = require('minimist')(process.argv.slice(2)); |
|
const bacon = require('baconipsum'); |
|
const bodyParser = require('body-parser'); |
|
const cors = require('./amp-cors'); |
|
const devDashboard = require('./app-index'); |
|
const express = require('express'); |
|
const formidable = require('formidable'); |
|
const fs = require('fs'); |
|
const jsdom = require('jsdom'); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const pc = process; |
|
const autocompleteEmailData = require('./autocomplete-test-data'); |
|
const header = require('connect-header'); |
|
const runVideoTestBench = require('./app-video-testbench'); |
|
const { |
|
getServeMode, |
|
isRtvMode, |
|
replaceUrls, |
|
toInaboxDocument, |
|
} = require('./app-utils'); |
|
const { |
|
getVariableRequest, |
|
runVariableSubstitution, |
|
saveVariableRequest, |
|
saveVariables, |
|
} = require('./variable-substitution'); |
|
const { |
|
recaptchaFrameRequestHandler, |
|
recaptchaRouter, |
|
} = require('./recaptcha-router'); |
|
const {logWithoutTimestamp} = require('../common/logging'); |
|
const {log} = require('../common/logging'); |
|
const {red} = require('kleur/colors'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
/** |
|
* Respond with content received from a URL when SERVE_MODE is "cdn". |
|
* @param {express.Response} res |
|
* @param {string} cdnUrl |
|
* @return {Promise<boolean>} |
|
*/ |
|
async function passthroughServeModeCdn(res, cdnUrl) { |
|
if (SERVE_MODE !== 'cdn') { |
|
return false; |
|
} |
|
try { |
|
const response = await fetch(cdnUrl); |
|
res.status(response.status); |
|
res.send(await response.text()); |
|
} catch (e) { |
|
log(red('ERROR:'), e); |
|
res.status(500); |
|
res.end(); |
|
} |
|
return true; |
|
} |
|
|
|
const app = express(); |
|
const TEST_SERVER_PORT = argv.port || 8000; |
|
let SERVE_MODE = getServeMode(); |
|
|
|
app.use(bodyParser.json()); |
|
app.use(bodyParser.text()); |
|
|
|
// Middleware is executed in order, so this must be at the top. |
|
// TODO(#24333): Migrate all server URL handlers to new-server/router and |
|
// deprecate app.js. |
|
app.use(require('./new-server/router')); |
|
|
|
app.use(require('./routes/a4a-envelopes')); |
|
app.use('/amp4test', require('./amp4test').app); |
|
app.use('/analytics', require('./routes/analytics')); |
|
app.use('/list/', require('./routes/list')); |
|
app.use('/test', require('./routes/test')); |
|
if (argv.coverage) { |
|
app.use('/coverage', require('istanbul-middleware').createHandler()); |
|
} |
|
|
|
// Built binaries should be fetchable from other origins, i.e. Storybook. |
|
app.use(header({'Access-Control-Allow-Origin': '*'})); |
|
|
|
// Append ?csp=1 to the URL to turn on the CSP header. |
|
// TODO: shall we turn on CSP all the time? |
|
app.use((req, res, next) => { |
|
if (req.query.csp) { |
|
res.set({ |
|
'content-security-policy': |
|
"default-src * blob: data:; script-src https://cdn.ampproject.org/rtv/ https://cdn.ampproject.org/v0.js https://cdn.ampproject.org/v0/ https://cdn.ampproject.org/viewer/ http://localhost:8000 https://localhost:8000; object-src 'none'; style-src 'unsafe-inline' https://cdn.ampproject.org/rtv/ https://cdn.materialdesignicons.com https://cloud.typography.com https://fast.fonts.net https://fonts.googleapis.com https://maxcdn.bootstrapcdn.com https://p.typekit.net https://use.fontawesome.com https://use.typekit.net https://cdnjs.cloudflare.com/ajax/libs/font-awesome/; report-uri https://csp-collector.appspot.com/csp/amp", |
|
}); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
* @return {boolean} |
|
*/ |
|
function isValidServeMode(serveMode) { |
|
return ( |
|
['default', 'minified', 'cdn', 'esm'].includes(serveMode) || |
|
isRtvMode(serveMode) |
|
); |
|
} |
|
|
|
/** |
|
* |
|
* @param {string} serveMode |
|
*/ |
|
function setServeMode(serveMode) { |
|
SERVE_MODE = serveMode; |
|
} |
|
|
|
app.get('/serve_mode=:mode', (req, res) => { |
|
const newMode = req.params.mode; |
|
if (isValidServeMode(newMode)) { |
|
setServeMode(newMode); |
|
res.send(`<h2>Serve mode changed to ${newMode}</h2>`); |
|
} else { |
|
const info = '<h2>Serve mode ' + newMode + ' is not supported. </h2>'; |
|
res.status(400).send(info); |
|
} |
|
}); |
|
|
|
if (argv._.includes('integration') && !argv.nobuild) { |
|
setServeMode('minified'); |
|
} |
|
|
|
if (!(argv._.includes('unit') || argv._.includes('integration'))) { |
|
// Dev dashboard routes break test scaffolding since they're global. |
|
devDashboard.installExpressMiddleware(app); |
|
} |
|
|
|
// Changes the current serve mode via query param |
|
// e.g. /serve_mode_change?mode=(default|minified|cdn|<RTV_NUMBER>) |
|
// (See ./app-index/settings.js) |
|
app.get('/serve_mode_change', (req, res) => { |
|
const {mode} = req.query; |
|
if (isValidServeMode(mode)) { |
|
setServeMode(mode); |
|
res.json({ok: true}); |
|
return; |
|
} |
|
res.status(400).json({ok: false}); |
|
}); |
|
|
|
// Redirects to a proxied document with optional mode through query params. |
|
// |
|
// Mode can be one of: |
|
// - '/', empty string, or unset for an unwrapped doc |
|
// - '/a4a/' for an AMP4ADS wrapper |
|
// - '/a4a-3p/' for a 3P AMP4ADS wrapper |
|
// - '/inabox/' for an AMP inabox wrapper |
|
// - '/shadow/' for a shadow-wrapped document |
|
// |
|
// Examples: |
|
// - /proxy/?url=hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com?mode=/shadow/ 👉 /shadow/proxy/s/hello.com |
|
// - /proxy/?url=https://hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=https://www.google.com/amp/s/hello.com 👉 /proxy/s/hello.com |
|
// - /proxy/?url=hello.com/canonical 👉 /proxy/s/hello.com/amp |
|
// |
|
// This passthrough is useful to generate the URL from <form> values, |
|
// (See ./app-index/proxy-form.js) |
|
app.get('/proxy', async (req, res, next) => { |
|
const {mode, url} = req.query; |
|
const urlSuffixClearPrefixReStr = |
|
'^https?://((www.)?google.(com?|[a-z]{2}|com?.[a-z]{2}|cat)/amp/s/)?'; |
|
const urlSuffix = url.replace(new RegExp(urlSuffixClearPrefixReStr, 'i'), ''); |
|
|
|
try { |
|
const ampdocUrl = await requestAmphtmlDocUrl(urlSuffix); |
|
const ampdocUrlSuffix = ampdocUrl.replace(/^https?:\/\//, ''); |
|
const modePrefix = (mode || '').replace(/\/$/, ''); |
|
const proxyUrl = `${modePrefix}/proxy/s/${ampdocUrlSuffix}`; |
|
res.redirect(proxyUrl); |
|
} catch ({message}) { |
|
logWithoutTimestamp(`ERROR: ${message}`); |
|
next(); |
|
} |
|
}); |
|
|
|
/** |
|
* Resolves an AMPHTML URL from a canonical URL. If AMPHTML is canonical, same |
|
* URL is returned. |
|
* @param {string} urlSuffix URL without protocol or google.com/amp/s/... |
|
* @param {string=} protocol 'https' or 'http'. 'https' retries using 'http'. |
|
* @return {!Promise<string>} |
|
*/ |
|
async function requestAmphtmlDocUrl(urlSuffix, protocol = 'https') { |
|
const defaultUrl = `${protocol}://${urlSuffix}`; |
|
logWithoutTimestamp(`Fetching URL: ${defaultUrl}`); |
|
|
|
const response = await fetch(defaultUrl); |
|
if (!response.ok) { |
|
if (protocol == 'https') { |
|
return requestAmphtmlDocUrl(urlSuffix, 'http'); |
|
} |
|
throw new Error(`Status: ${response.status}`); |
|
} |
|
|
|
const {window} = new jsdom.JSDOM(await response.text()); |
|
const linkRelAmphtml = window.document.querySelector('link[rel=amphtml]'); |
|
const amphtmlUrl = linkRelAmphtml && linkRelAmphtml.getAttribute('href'); |
|
return amphtmlUrl || defaultUrl; |
|
} |
|
|
|
/* |
|
* Intercept Recaptcha frame for, |
|
* integration tests. Using this to mock |
|
* out the recaptcha api. |
|
*/ |
|
app.get('/dist.3p/*/recaptcha.*html', recaptchaFrameRequestHandler); |
|
app.use('/recaptcha', recaptchaRouter); |
|
|
|
// Deprecate usage of .min.html/.max.html |
|
app.get( |
|
[ |
|
'/examples/*.(min|max).html', |
|
'/test/manual/*.(min|max).html', |
|
'/test/fixtures/e2e/*/*.(min|max).html', |
|
], |
|
(req, res) => { |
|
const filePath = req.url; |
|
res.send(generateInfo(filePath)); |
|
} |
|
); |
|
|
|
app.use('/pwa', (req, res) => { |
|
let file; |
|
let contentType; |
|
if (!req.url || req.path == '/') { |
|
// pwa.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa.html'; |
|
} else if (req.url == '/pwa.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa.js'; |
|
} else if (req.url == '/pwa-sw.js') { |
|
// pwa.js |
|
contentType = 'application/javascript'; |
|
file = '/examples/pwa/pwa-sw.js'; |
|
} else if (req.url == '/ampdoc-shell') { |
|
// pwa-ampdoc-shell.html |
|
contentType = 'text/html'; |
|
file = '/examples/pwa/pwa-ampdoc-shell.html'; |
|
} else { |
|
// Redirect to the underlying resource. |
|
// TODO(dvoytenko): would be nicer to do forward instead of redirect. |
|
res.writeHead(302, {'Location': req.url}); |
|
res.end(); |
|
return; |
|
} |
|
res.statusCode = 200; |
|
res.setHeader('Content-Type', contentType); |
|
fs.promises.readFile(pc.cwd() + file).then((file) => { |
|
res.end(file); |
|
}); |
|
}); |
|
|
|
app.use('/api/show', (_req, res) => { |
|
res.json({ |
|
showNotification: true, |
|
}); |
|
}); |
|
|
|
app.use('/api/dont-show', (_req, res) => { |
|
res.json({ |
|
showNotification: false, |
|
}); |
|
}); |
|
|
|
app.use('/api/echo/query', (req, res) => { |
|
res.json(JSON.parse(req.query.data)); |
|
}); |
|
|
|
app.use('/api/echo/post', (req, res) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(req.body); |
|
}); |
|
|
|
app.use('/api/ping', (_req, res) => { |
|
res.status(204).end(); |
|
}); |
|
|
|
app.use('/form/html/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
res.end(` |
|
<h1 style="color:red;">Sorry ${fields['name']}!</h1> |
|
<p>The email ${fields['email']} is already subscribed!</p> |
|
`); |
|
} else { |
|
res.end(` |
|
<h1>Thanks ${fields['name']}!</h1> |
|
<p>Please make sure to confirm your email ${fields['email']}</p> |
|
`); |
|
} |
|
}); |
|
}); |
|
|
|
app.use('/form/redirect-to/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST'], ['AMP-Redirect-To']); |
|
res.setHeader('AMP-Redirect-To', 'https://google.com'); |
|
res.end('{}'); |
|
}); |
|
|
|
app.use('/form/echo-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
const fields = Object.create(null); |
|
form.on('field', function (name, value) { |
|
if (!(name in fields)) { |
|
fields[name] = value; |
|
return; |
|
} |
|
|
|
const realName = name; |
|
if (realName in fields) { |
|
if (!Array.isArray(fields[realName])) { |
|
fields[realName] = [fields[realName]]; |
|
} |
|
} else { |
|
fields[realName] = []; |
|
} |
|
fields[realName].push(value); |
|
}); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
if (fields['email'] == '[email protected]') { |
|
res.statusCode = 500; |
|
} |
|
res.end(JSON.stringify(fields)); |
|
}); |
|
}); |
|
|
|
app.use('/form/json/poll1', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, () => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end( |
|
JSON.stringify({ |
|
result: [ |
|
{ |
|
answer: 'Penguins', |
|
percentage: new Array(77), |
|
}, |
|
{ |
|
answer: 'Ostriches', |
|
percentage: new Array(8), |
|
}, |
|
{ |
|
answer: 'Kiwis', |
|
percentage: new Array(14), |
|
}, |
|
{ |
|
answer: 'Wekas', |
|
percentage: new Array(1), |
|
}, |
|
], |
|
}) |
|
); |
|
}); |
|
}); |
|
|
|
app.post('/form/json/upload', upload.fields([{name: 'myFile'}]), (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
const myFile = req.files['myFile']; |
|
|
|
if (!myFile) { |
|
res.json({message: 'No file data received'}); |
|
return; |
|
} |
|
const fileData = myFile[0]; |
|
const contents = fileData.buffer.toString(); |
|
|
|
res.json({message: contents}); |
|
}); |
|
|
|
app.use('/form/search-html/get', (_req, res) => { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(` |
|
<h1>Here's results for your search<h1> |
|
<ul> |
|
<li>Result 1</li> |
|
<li>Result 2</li> |
|
<li>Result 3</li> |
|
</ul> |
|
`); |
|
}); |
|
|
|
app.use('/form/search-json/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
term: req.query.term, |
|
additionalFields: req.query.additionalFields, |
|
results: [{title: 'Result 1'}, {title: 'Result 2'}, {title: 'Result 3'}], |
|
}); |
|
}); |
|
|
|
const autocompleteColors = [ |
|
'red', |
|
'orange', |
|
'yellow', |
|
'green', |
|
'blue', |
|
'purple', |
|
'pink', |
|
'black', |
|
'white', |
|
]; |
|
|
|
app.use('/form/autocomplete/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteColors}); |
|
} else { |
|
const lowerCaseQuery = query.toLowerCase(); |
|
const filtered = autocompleteColors.filter((l) => |
|
l.toLowerCase().includes(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
} |
|
}); |
|
|
|
app.use('/form/autocomplete/error', (_req, res) => { |
|
res.status(500).end(); |
|
}); |
|
|
|
app.use('/form/mention/query', (req, res) => { |
|
const query = req.query.q; |
|
if (!query) { |
|
res.json({items: autocompleteEmailData}); |
|
return; |
|
} |
|
const lowerCaseQuery = query.toLowerCase().trim(); |
|
const filtered = autocompleteEmailData.filter((l) => |
|
l.toLowerCase().startsWith(lowerCaseQuery) |
|
); |
|
res.json({items: filtered}); |
|
}); |
|
|
|
app.use('/form/verify-search-json/post', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const form = new formidable.IncomingForm(); |
|
form.parse(req, (_err, fields) => { |
|
res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
|
|
|
const errors = []; |
|
if (!fields.phone.match(/^650/)) { |
|
errors.push({name: 'phone', message: 'Phone must start with 650'}); |
|
} |
|
if (fields.name !== 'Frank') { |
|
errors.push({name: 'name', message: 'Please set your name to be Frank'}); |
|
} |
|
if (fields.error === 'true') { |
|
errors.push({message: 'You asked for an error, you get an error.'}); |
|
} |
|
if (fields.city !== 'Mountain View' || fields.zip !== '94043') { |
|
errors.push({ |
|
name: 'city', |
|
message: "City doesn't match zip (Mountain View and 94043)", |
|
}); |
|
} |
|
|
|
if (errors.length === 0) { |
|
res.end( |
|
JSON.stringify({ |
|
results: [ |
|
{title: 'Result 1'}, |
|
{title: 'Result 2'}, |
|
{title: 'Result 3'}, |
|
], |
|
committed: true, |
|
}) |
|
); |
|
} else { |
|
res.statusCode = 400; |
|
res.end(JSON.stringify({verifyErrors: errors})); |
|
} |
|
}); |
|
}); |
|
|
|
/** |
|
* Fetches an AMP document from the AMP proxy and replaces JS |
|
* URLs, so that they point to localhost. |
|
* |
|
* @param {express.Request} req |
|
* @param {express.Response} res |
|
* @param {string} mode |
|
* @return {Promise<void>} |
|
*/ |
|
async function proxyToAmpProxy(req, res, mode) { |
|
const url = |
|
'https://cdn.ampproject.org/' + |
|
(req.query['amp_js_v'] ? 'v' : 'c') + |
|
req.url; |
|
logWithoutTimestamp('Fetching URL: ' + url); |
|
const urlResponse = await fetch(url); |
|
let body = await urlResponse.text(); |
|
body = body |
|
// Unversion URLs. |
|
.replace( |
|
/https\:\/\/cdn\.ampproject\.org\/rtv\/\d+\//g, |
|
'https://cdn.ampproject.org/' |
|
) |
|
// <base> href pointing to the proxy, so that images, etc. still work. |
|
.replace('<head>', '<head><base href="https://cdn.ampproject.org/">'); |
|
const inabox = req.query['inabox']; |
|
const urlPrefix = getUrlPrefix(req); |
|
if (req.query['mraid']) { |
|
body = body |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
// Change cdnUrl from the default so amp-mraid requests the (mock) |
|
// mraid.js from the local server. In a real environment this doesn't |
|
// matter as the local environment would intercept this request. |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
if (inabox) { |
|
body = toInaboxDocument(body); |
|
// Allow CORS requests for A4A. |
|
const origin = req.headers.origin || urlPrefix; |
|
cors.enableCors(req, res, origin); |
|
} |
|
body = replaceUrls(mode, body, urlPrefix); |
|
res.status(urlResponse.status).send(body); |
|
} |
|
|
|
let itemCtr = 2; |
|
const doctype = '<!doctype html>\n'; |
|
const liveListDocs = Object.create(null); |
|
app.use('/examples/live-list-update(-reverse)?.amp.html', (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
let liveListDoc = liveListDocs[req.baseUrl]; |
|
if (mode != 'minified' && mode != 'default') { |
|
// Only handle compile(prev min)/default (prev max) mode |
|
next(); |
|
return; |
|
} |
|
// When we already have state in memory and user refreshes page, we flush |
|
// the dom we maintain on the server. |
|
if (!('amp_latest_update_time' in req.query) && liveListDoc) { |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
res.send(`${doctype}${outerHTML}`); |
|
return; |
|
} |
|
if (!liveListDoc) { |
|
const liveListUpdateFullPath = `${pc.cwd()}${req.baseUrl}`; |
|
logWithoutTimestamp('liveListUpdateFullPath', liveListUpdateFullPath); |
|
const liveListFile = fs.readFileSync(liveListUpdateFullPath); |
|
liveListDoc = liveListDocs[req.baseUrl] = new jsdom.JSDOM( |
|
liveListFile |
|
).window.document; |
|
liveListDoc.ctr = 0; |
|
} |
|
const liveList = liveListDoc.querySelector('#my-live-list'); |
|
const perPage = Number(liveList.getAttribute('data-max-items-per-page')); |
|
const items = liveList.querySelector('[items]'); |
|
const pagination = liveListDoc.querySelector('#my-live-list [pagination]'); |
|
const item1 = liveList.querySelector('#list-item-1'); |
|
if (liveListDoc.ctr != 0) { |
|
if (Math.random() < 0.8) { |
|
// Always run a replace on the first item |
|
liveListReplace(item1); |
|
|
|
if (Math.random() < 0.5) { |
|
liveListTombstone(liveList); |
|
} |
|
|
|
if (Math.random() < 0.8) { |
|
liveListInsert(liveList, item1); |
|
} |
|
pagination.textContent = ''; |
|
const liveChildren = [].slice |
|
.call(items.children) |
|
.filter((x) => !x.hasAttribute('data-tombstone')); |
|
|
|
const pageCount = Math.ceil(liveChildren.length / perPage); |
|
const pageListItems = Array.apply(null, Array(pageCount)) |
|
.map((_, i) => `<li>${i + 1}</li>`) |
|
.join(''); |
|
const newPagination = |
|
'<nav aria-label="amp live list pagination">' + |
|
`<ul class="pagination">${pageListItems}</ul>` + |
|
'</nav>'; |
|
pagination./*OK*/ innerHTML = newPagination; |
|
} else { |
|
// Sometimes we want an empty response to simulate no changes. |
|
res.send(`${doctype}<html></html>`); |
|
return; |
|
} |
|
} |
|
let outerHTML = liveListDoc.documentElement./*OK*/ outerHTML; |
|
outerHTML = replaceUrls(mode, outerHTML); |
|
liveListDoc.ctr++; |
|
res.send(`${doctype}${outerHTML}`); |
|
}); |
|
|
|
/** |
|
* @param {Element} item |
|
*/ |
|
function liveListReplace(item) { |
|
item.setAttribute('data-update-time', Date.now().toString()); |
|
const itemContents = item.querySelectorAll('.content'); |
|
itemContents[0].textContent = Math.floor(Math.random() * 10).toString(); |
|
itemContents[1].textContent = Math.floor(Math.random() * 10).toString(); |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
* @param {Element} node |
|
*/ |
|
function liveListInsert(liveList, node) { |
|
const iterCount = Math.floor(Math.random() * 2) + 1; |
|
logWithoutTimestamp(`inserting ${iterCount} item(s)`); |
|
for (let i = 0; i < iterCount; i++) { |
|
/** |
|
* TODO(#28387) this type cast may be hiding a bug. |
|
* @type {Element} |
|
*/ |
|
const child = /** @type {*} */ (node.cloneNode(true)); |
|
child.setAttribute('id', `list-item-${itemCtr++}`); |
|
child.setAttribute('data-sort-time', Date.now().toString()); |
|
liveList.querySelector('[items]')?.appendChild(child); |
|
} |
|
} |
|
|
|
/** |
|
* @param {Element} liveList |
|
*/ |
|
function liveListTombstone(liveList) { |
|
const tombstoneId = Math.floor(Math.random() * itemCtr); |
|
logWithoutTimestamp(`trying to tombstone #list-item-${tombstoneId}`); |
|
// We can tombstone any list item except item-1 since we always do a |
|
// replace example on item-1. |
|
if (tombstoneId != 1) { |
|
const item = liveList./*OK*/ querySelector(`#list-item-${tombstoneId}`); |
|
if (item) { |
|
item.setAttribute('data-tombstone', ''); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Generate a random number between min and max |
|
* Value is inclusive of both min and max values. |
|
* |
|
* @param {number} min |
|
* @param {number} max |
|
* @return {number} |
|
*/ |
|
function range(min, max) { |
|
const values = Array.apply(null, new Array(max - min + 1)).map( |
|
(_, i) => min + i |
|
); |
|
return values[Math.round(Math.random() * (max - min))]; |
|
} |
|
|
|
/** |
|
* Returns the result of a coin flip, true or false |
|
* |
|
* @return {boolean} |
|
*/ |
|
function flip() { |
|
return !!Math.floor(Math.random() * 2); |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItem() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const headline = bacon(range(3, 7)); |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
const img = `<amp-img |
|
src="${ |
|
flip() |
|
? 'https://placekitten.com/300/350' |
|
: 'https://baconmockup.com/300/350' |
|
}" |
|
layout="responsive" |
|
height="300" width="350"> |
|
</amp-img>`; |
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<h3 class="headline"> |
|
<a href="#live-blog-item-${now}">${headline}</a> |
|
</h3> |
|
<div class="author"> |
|
<div class="byline"> |
|
<p> |
|
by <span itemscope itemtype="http://schema.org/Person" |
|
itemprop="author"><b>Lorem Ipsum</b> |
|
<a class="mailto" href="mailto:lorem.ipsum@"> |
|
lorem.ipsum@</a></span> |
|
</p> |
|
<p class="brand">PublisherName News Reporter<p> |
|
<p><span itemscope itemtype="http://schema.org/Date" |
|
itemprop="Date"> |
|
${new Date(now).toString().replace(/ GMT.*$/, '')} |
|
<span></p> |
|
</div> |
|
</div> |
|
<div class="article-body">${body}</div> |
|
${img} |
|
<div class="social-box"> |
|
<amp-social-share type="facebook" |
|
data-param-text="Hello world" |
|
data-param-href="https://example.test/?ref=URL" |
|
data-param-app_id="145634995501895"></amp-social-share> |
|
<amp-social-share type="twitter"></amp-social-share> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
/** |
|
* @return {string} |
|
*/ |
|
function getLiveBlogItemWithBindAttributes() { |
|
const now = Date.now(); |
|
// Generate a 3 to 7 worded headline |
|
const numOfParagraphs = range(1, 2); |
|
const body = Array.apply(null, new Array(numOfParagraphs)) |
|
.map(() => { |
|
return `<p>${bacon(range(50, 90))}</p>`; |
|
}) |
|
.join('\n'); |
|
|
|
return `<!doctype html> |
|
<html amp><body> |
|
<amp-live-list id="live-blog-1"> |
|
<div items> |
|
<div id="live-blog-item-${now}" data-sort-time="${now}"> |
|
<div class="article-body"> |
|
${body} |
|
<p> As you can see, bacon is far superior to |
|
<b><span [text]='favoriteFood'>everything!</span></b>!</p> |
|
</div> |
|
</div> |
|
</div> |
|
</amp-live-list></body></html>`; |
|
} |
|
|
|
app.use( |
|
'/examples/live-blog(-non-floating-button)?.amp.html', |
|
(req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItem()); |
|
return; |
|
} |
|
next(); |
|
} |
|
); |
|
|
|
app.use('/examples/bind/live-list.amp.html', (req, res, next) => { |
|
if ('amp_latest_update_time' in req.query) { |
|
res.setHeader('Content-Type', 'text/html'); |
|
res.end(getLiveBlogItemWithBindAttributes()); |
|
return; |
|
} |
|
next(); |
|
}); |
|
|
|
app.use('/impression-proxy/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
// Fake response with the following optional fields: |
|
// location: The Url the that server would have sent redirect to w/o ALP |
|
// tracking_url: URL that should be requested to track click |
|
// gclid: The conversion tracking value |
|
const body = { |
|
'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', |
|
'tracking_url': 'tracking_url', |
|
'gclid': '1234', |
|
}; |
|
res.send(body); |
|
|
|
// Or fake response with status 204 if viewer replaceUrl is provided |
|
}); |
|
|
|
/** |
|
* Acts in a similar fashion to /serve_mode_change. Saves |
|
* analytics requests via /run-variable-substitution, and |
|
* then returns the encoded/substituted/replaced request |
|
* via /get-variable-request. |
|
*/ |
|
|
|
// Saves the variables input to be used in run-variable-substitution |
|
app.get('/save-variables', saveVariables); |
|
|
|
// Creates an iframe with amp-analytics. Analytics request |
|
// uses save-variable-request as its endpoint. |
|
app.get('/run-variable-substitution', runVariableSubstitution); |
|
|
|
// Saves the analytics request to the dev server. |
|
app.get('/save-variable-request', saveVariableRequest); |
|
|
|
// Returns the saved analytics request. |
|
app.get('/get-variable-request', getVariableRequest); |
|
|
|
let forcePromptOnNext = false; |
|
app.post('/get-consent-v1/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = { |
|
'promptIfUnknown': true, |
|
'purposeConsentRequired': ['purpose-foo', 'purpose-bar'], |
|
'forcePromptOnNext': forcePromptOnNext, |
|
'sharedData': { |
|
'tfua': true, |
|
'coppa': true, |
|
}, |
|
}; |
|
res.json(body); |
|
}); |
|
|
|
app.get('/get-consent-v1-set/', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const value = req.query['forcePromptOnNext']; |
|
if (value == 'false' || value == '0') { |
|
forcePromptOnNext = false; |
|
} else { |
|
forcePromptOnNext = true; |
|
} |
|
res.json({}); |
|
res.end(); |
|
}); |
|
|
|
app.post('/get-consent-no-prompt/', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const body = {}; |
|
res.json(body); |
|
}); |
|
|
|
app.post('/check-consent', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
const response = { |
|
'consentRequired': req.query.consentRequired === 'true', |
|
'consentStateValue': req.query.consentStateValue, |
|
'consentString': req.query.consentString, |
|
'expireCache': req.query.expireCache === 'true', |
|
}; |
|
if (req.query.consentMetadata) { |
|
response['consentMetadata'] = JSON.parse( |
|
req.query.consentMetadata.replace(/'/g, '"') |
|
); |
|
} |
|
res.json(response); |
|
}); |
|
|
|
// Proxy with local JS. |
|
// Example: |
|
// http://localhost:8000/proxy/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ |
|
app.use('/proxy/', (req, res) => proxyToAmpProxy(req, res, SERVE_MODE)); |
|
|
|
// Nest the response in an iframe. |
|
// Example: |
|
// http://localhost:8000/iframe/examples/ads.amp.html |
|
app.get('/iframe/*', (req, res) => { |
|
// Returns an html blob with an iframe pointing to the url after /iframe/. |
|
res.send(`<!doctype html> |
|
<html style="width:100%; height:100%;"> |
|
<body style="width:98%; height:98%;"> |
|
<iframe src="${req.url.substr(7)}" |
|
style="width:100%; height:100%;"> |
|
</iframe> |
|
</body> |
|
</html>`); |
|
}); |
|
|
|
app.get('/a4a_template/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
const match = /^\/a4a_template\/([a-z-]+)\/(\d+)$/.exec(req.path); |
|
if (!match) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
`${pc.cwd()}/extensions/amp-ad-network-${match[1]}-impl/` + |
|
`0.1/data/${match[2]}.template`; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-template-amp-creative', 'amp-mustache'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
// Returns a document that echoes any post messages received from parent. |
|
// An optional `message` query param can be appended for an initial post |
|
// message sent on document load. |
|
// Example: |
|
// http://localhost:8000/iframe-echo-message?message=${payload} |
|
app.get('/iframe-echo-message', (req, res) => { |
|
@@ -941,1 +942,1 @@ |
|
- const {message} = req.query; |
|
+ const message = escape(req.query.message); |
|
res.send( |
|
`<!doctype html> |
|
<body style="background-color: yellow"> |
|
<script> |
|
if (${message}) { |
|
echoMessage(${message}); |
|
} |
|
window.addEventListener('message', function(event) { |
|
echoMessage(event.data); |
|
}); |
|
function echoMessage(message) { |
|
parent.postMessage(message, '*'); |
|
} |
|
</script> |
|
</body> |
|
</html>` |
|
); |
|
}); |
|
|
|
/** |
|
* Append ?sleep=5 to any included JS file in examples to emulate delay in |
|
* loading that file. This allows you to test issues with your extension being |
|
* late to load and testing user interaction with your element before your code |
|
* loads. |
|
* |
|
* Example delay loading amp-form script by 5 seconds: |
|
* <script async custom-element="amp-form" |
|
* src="https://cdn.ampproject.org/v0/amp-form-0.1.js?sleep=5"></script> |
|
*/ |
|
app.use(['/dist/v0/amp-*.(m?js)', '/dist/amp*.(m?js)'], (req, _res, next) => { |
|
const sleep = parseInt(req.query.sleep || 0, 10) * 1000; |
|
setTimeout(next, sleep); |
|
}); |
|
|
|
/** |
|
* Disable caching for extensions if the --no_caching_extensions flag is used. |
|
*/ |
|
app.get(['/dist/v0/amp-*.(m?js)'], (_req, res, next) => { |
|
if (argv.no_caching_extensions) { |
|
res.header('Cache-Control', 'no-store'); |
|
} |
|
next(); |
|
}); |
|
|
|
/** |
|
* Video testbench endpoint |
|
*/ |
|
app.get('/test/manual/amp-video.amp.html', runVideoTestBench); |
|
|
|
app.get( |
|
[ |
|
'/examples/(**/)?*.html', |
|
'/test/manual/(**/)?*.html', |
|
'/test/fixtures/e2e/(**/)?*.html', |
|
'/test/fixtures/performance/(**/)?*.html', |
|
], |
|
(req, res, next) => { |
|
const filePath = req.path; |
|
const mode = SERVE_MODE; |
|
const inabox = req.query['inabox']; |
|
const stream = Number(req.query['stream']); |
|
const componentVersion = req.query['componentVersion']; |
|
const urlPrefix = getUrlPrefix(req); |
|
fs.promises |
|
.readFile(pc.cwd() + filePath, 'utf8') |
|
.then((file) => { |
|
if (req.query['amp_js_v']) { |
|
file = addViewerIntegrationScript(req.query['amp_js_v'], file); |
|
} |
|
if (req.query['mraid']) { |
|
file = file |
|
.replace( |
|
'</head>', |
|
'<script async host-service="amp-mraid" src="https://cdn.ampproject.org/v0/amp-mraid-0.1.js">' + |
|
'</script>' + |
|
'</head>' |
|
) |
|
.replace( |
|
'<head>', |
|
' <head>' + |
|
' <script>' + |
|
' window.AMP_CONFIG = {' + |
|
` cdnUrl: "${urlPrefix}",` + |
|
' };' + |
|
' </script>' |
|
); |
|
} |
|
file = file.replace(/__TEST_SERVER_PORT__/g, TEST_SERVER_PORT); |
|
if (componentVersion) { |
|
file = file.replace(/-latest.js/g, `-${componentVersion}.js`); |
|
} |
|
|
|
if (inabox) { |
|
file = toInaboxDocument(file); |
|
// Allow CORS requests for A4A. |
|
if (req.headers.origin) { |
|
cors.enableCors(req, res, req.headers.origin); |
|
} |
|
} |
|
|
|
file = replaceUrls(mode, file); |
|
|
|
const ampExperimentsOptIn = req.query['exp']; |
|
if (ampExperimentsOptIn) { |
|
file = file.replace( |
|
'<head>', |
|
`<head><meta name="amp-experiments-opt-in" content="${ampExperimentsOptIn}">` |
|
); |
|
} |
|
|
|
// Extract amp-ad for the given 'type' specified in URL query. |
|
if (req.path.indexOf('/examples/ads.amp.html') == 0 && req.query.type) { |
|
const ads = |
|
file.match( |
|
elementExtractor('(amp-ad|amp-embed)', req.query.type) |
|
) ?? []; |
|
file = file.replace( |
|
/<body>[\s\S]+<\/body>/m, |
|
'<body>' + ads.join('') + '</body>' |
|
); |
|
} |
|
|
|
// Extract amp-analytics for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/analytics-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const analytics = |
|
file.match(elementExtractor('amp-analytics', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + analytics.join('') + '</div>' |
|
); |
|
} |
|
|
|
// Extract amp-consent for the given 'type' specified in URL query. |
|
if ( |
|
req.path.indexOf('/examples/amp-consent/cmp-vendors.amp.html') == 0 && |
|
req.query.type |
|
) { |
|
const consent = |
|
file.match(elementExtractor('amp-consent', req.query.type)) ?? []; |
|
file = file.replace( |
|
/<div id="container">[\s\S]+<\/div>/m, |
|
'<div id="container">' + consent.join('') + '</div>' |
|
); |
|
} |
|
|
|
if (stream > 0) { |
|
res.writeHead(200, {'Content-Type': 'text/html'}); |
|
let pos = 0; |
|
const writeChunk = function () { |
|
const chunk = file.substring( |
|
pos, |
|
Math.min(pos + stream, file.length) |
|
); |
|
res.write(chunk); |
|
pos += stream; |
|
if (pos < file.length) { |
|
setTimeout(writeChunk, 500); |
|
} else { |
|
res.end(); |
|
} |
|
}; |
|
writeChunk(); |
|
} else { |
|
res.send(file); |
|
} |
|
}) |
|
.catch(() => { |
|
next(); |
|
}); |
|
} |
|
); |
|
|
|
/** |
|
* @param {string} string |
|
* @return {string} |
|
*/ |
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
/** |
|
* @param {string} tagName |
|
* @param {string} type |
|
* @return {RegExp} |
|
*/ |
|
function elementExtractor(tagName, type) { |
|
type = escapeRegExp(type); |
|
return new RegExp( |
|
`<${tagName}[\\s][^>]*['"]${type}['"][^>]*>([\\s\\S]+?)</${tagName}>`, |
|
'gm' |
|
); |
|
} |
|
|
|
// Data for example: http://localhost:8000/examples/bind/xhr.amp.html |
|
app.use('/bind/form/get', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
bindXhrResult: 'I was fetched from the server!', |
|
}); |
|
}); |
|
|
|
// Data for example: http://localhost:8000/examples/bind/ecommerce.amp.html |
|
app.use('/bind/ecommerce/sizes', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
setTimeout(() => { |
|
const prices = { |
|
'0': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
}, |
|
}, |
|
'1': { |
|
'sizes': { |
|
'S': 10.99, |
|
'M': 12.99, |
|
'L': 14.99, |
|
}, |
|
}, |
|
'2': { |
|
'sizes': { |
|
'L': 11.99, |
|
'XL': 13.99, |
|
}, |
|
}, |
|
'3': { |
|
'sizes': { |
|
'M': 7.99, |
|
'L': 9.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'4': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 10.99, |
|
'L': 15.99, |
|
}, |
|
}, |
|
'5': { |
|
'sizes': { |
|
'S': 8.99, |
|
'L': 14.99, |
|
'XL': 11.99, |
|
}, |
|
}, |
|
'6': { |
|
'sizes': { |
|
'XS': 8.99, |
|
'S': 9.99, |
|
'M': 12.99, |
|
}, |
|
}, |
|
'7': { |
|
'sizes': { |
|
'M': 10.99, |
|
'L': 11.99, |
|
}, |
|
}, |
|
}; |
|
const object = {}; |
|
object[req.query.shirt] = prices[req.query.shirt]; |
|
res.json(object); |
|
}, 1000); // Simulate network delay. |
|
}); |
|
|
|
/** |
|
* Simulates a publisher's metering state store. |
|
* (amp-subscriptions) |
|
* @type {{[ampReaderId: string]: {}}} |
|
*/ |
|
const meteringStateStore = {}; |
|
|
|
// Simulate a publisher's entitlements API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/:id/entitlements', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
|
|
// Create entitlements response. |
|
const source = 'local' + req.params.id; |
|
const granted = req.params.id > 0; |
|
const grantReason = granted ? 'SUBSCRIBER' : 'NOT_SUBSCRIBER'; |
|
const decryptedDocumentKey = decryptDocumentKey(req.query.crypt); |
|
const response = { |
|
source, |
|
granted, |
|
grantReason, |
|
data: { |
|
login: true, |
|
}, |
|
decryptedDocumentKey, |
|
}; |
|
|
|
// Store metering state, if possible. |
|
const ampReaderId = req.query.rid; |
|
if (ampReaderId && req.query.meteringState) { |
|
// Parse metering state from encoded Base64 string. |
|
const encodedMeteringState = req.query.meteringState; |
|
const decodedMeteringState = Buffer.from( |
|
encodedMeteringState, |
|
'base64' |
|
).toString(); |
|
const meteringState = JSON.parse(decodedMeteringState); |
|
|
|
// Store metering state. |
|
meteringStateStore[ampReaderId] = meteringState; |
|
} |
|
|
|
// Add metering state to response, if possible. |
|
if (meteringStateStore[ampReaderId]) { |
|
response.metering = { |
|
state: meteringStateStore[ampReaderId], |
|
}; |
|
} |
|
|
|
res.json(response); |
|
}); |
|
|
|
// Simulate a publisher's SKU map API. |
|
// (amp-subscriptions) |
|
app.use('/subscriptions/skumap', (req, res) => { |
|
cors.assertCors(req, res, ['GET']); |
|
res.json({ |
|
'subscribe.google.com': { |
|
'subscribeButtonSimple': { |
|
'sku': 'basic', |
|
}, |
|
'subscribeButtonCarousel': { |
|
'carouselOptions': { |
|
'skus': ['basic', 'premium_monthly'], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulate a publisher's pingback API. |
|
// (amp-subscriptions) |
|
app.use('/subscription/pingback', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
res.json({ |
|
done: true, |
|
}); |
|
}); |
|
|
|
/* |
|
Simulate a publisher's account registration API. |
|
|
|
The `amp-subscriptions-google` extension sends this API a POST request. |
|
The request body looks like: |
|
|
|
{ |
|
"googleSignInDetails": { |
|
// This signed JWT contains information from Google Sign-In |
|
"idToken": "...JWT from Google Sign-In...", |
|
// Some useful fields from the `idToken`, pre-parsed for convenience |
|
"name": "Jane Smith", |
|
"givenName": "Jane", |
|
"familyName": "Smith", |
|
"imageUrl": "https://imageurl", |
|
"email": "[email protected]" |
|
}, |
|
// Associate this ID with the registration. Use it to look up metering state |
|
// for future entitlements requests |
|
// https://github.com/ampproject/amphtml/blob/main/extensions/amp-subscriptions/amp-subscriptions.md#combining-the-amp-reader-id-with-publisher-cookies |
|
"ampReaderId": "amp-s0m31d3nt1f13r" |
|
} |
|
|
|
(amp-subscriptions-google) |
|
*/ |
|
app.use('/subscription/register', (req, res) => { |
|
cors.assertCors(req, res, ['POST']); |
|
|
|
// Generate a new ID for this metering state. |
|
const meteringStateId = 'ppid' + Math.round(Math.random() * 99999999); |
|
|
|
// Define registration timestamp. |
|
// |
|
// For demo purposes, set timestamp to 30 seconds ago. |
|
// This causes Metering Toast to show immediately, |
|
// which helps engineers test metering. |
|
const registrationTimestamp = Math.round(Date.now() / 1000) - 30000; |
|
|
|
// Store metering state. |
|
// |
|
// For demo purposes, just save this in memory. |
|
// Production systems should persist this. |
|
meteringStateStore[req.body.ampReaderId] = { |
|
id: meteringStateId, |
|
standardAttributes: { |
|
// eslint-disable-next-line local/camelcase |
|
registered_user: { |
|
timestamp: registrationTimestamp, // In seconds. |
|
}, |
|
}, |
|
}; |
|
|
|
res.json({ |
|
metering: { |
|
state: meteringStateStore[req.body.ampReaderId], |
|
}, |
|
}); |
|
}); |
|
|
|
// Simulated adzerk ad server and AMP cache CDN. |
|
app.get('/adzerk/*', (req, res) => { |
|
cors.assertCors(req, res, ['GET'], ['AMP-template-amp-creative']); |
|
const match = /\/(\d+)/.exec(req.path); |
|
if (!match || !match[1]) { |
|
res.status(404); |
|
res.end('Invalid path: ' + req.path); |
|
return; |
|
} |
|
const filePath = |
|
pc.cwd() + '/extensions/amp-ad-network-adzerk-impl/0.1/data/' + match[1]; |
|
fs.promises |
|
.readFile(filePath) |
|
.then((file) => { |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.setHeader('AMP-Ad-Template-Extension', 'amp-mustache'); |
|
res.setHeader('AMP-Ad-Response-Type', 'template'); |
|
res.end(file); |
|
}) |
|
.catch(() => { |
|
res.status(404); |
|
res.end('Not found: ' + filePath); |
|
}); |
|
}); |
|
|
|
app.get('/dist/*.mjs', (req, res, next) => { |
|
// Allow CORS access control explicitly for mjs files |
|
cors.enableCors(req, res); |
|
next(); |
|
}); |
|
|
|
/* |
|
* Serve extension scripts and their source maps. |
|
*/ |
|
app.get( |
|
['/dist/rtv/*/v0/*.(m?js)', '/dist/rtv/*/v0/*.(m?js).map'], |
|
async (req, res, next) => { |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path).replace('.max.', '.'); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
if (await passthroughServeModeCdn(res, filePath)) { |
|
return; |
|
} |
|
const isJsMap = filePath.endsWith('.map'); |
|
if (isJsMap) { |
|
filePath = filePath.replace(/\.(m?js)\.map$/, '.$1'); |
|
} |
|
filePath = replaceUrls(mode, filePath); |
|
req.url = filePath + (isJsMap ? '.map' : ''); |
|
next(); |
|
} |
|
); |
|
|
|
/** |
|
* Handle amp-story translation file requests with an rtv path. |
|
* We need to make sure we only handle the amp-story requests since this |
|
* can affect other tests with json requests. |
|
*/ |
|
app.get('/dist/rtv/*/v0/amp-story*.json', async (req, _res, next) => { |
|
const fileName = path.basename(req.path); |
|
let filePath = 'https://cdn.ampproject.org/v0/' + fileName; |
|
filePath = replaceUrls(SERVE_MODE, filePath); |
|
req.url = filePath; |
|
next(); |
|
}); |
|
|
|
if (argv.coverage === 'live') { |
|
app.get('/dist/amp.js', async (req, res) => { |
|
const ampJs = await fs.promises.readFile(`${pc.cwd()}${req.path}`); |
|
res.setHeader('Content-Type', 'text/javascript'); |
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
// Append an unload handler that reports coverage information each time you |
|
// leave a page. |
|
res.end(`${ampJs}; |
|
window.addEventListener('beforeunload', (evt) => { |
|
const COV_REPORT_URL = 'http://localhost:${TEST_SERVER_PORT}/coverage/client'; |
|
console.info('POSTing code coverage to', COV_REPORT_URL); |
|
|
|
const xhr = new XMLHttpRequest(); |
|
xhr.open('POST', COV_REPORT_URL, true); |
|
xhr.setRequestHeader('Content-type', 'application/json'); |
|
xhr.send(JSON.stringify(window.__coverage__)); |
|
|
|
// Required by Chrome |
|
evt.returnValue = ''; |
|
return null; |
|
});`); |
|
}); |
|
} |
|
|
|
app.get('/dist/ww.(m?js)', async (req, res, next) => { |
|
// Special case for entry point script url. Use minified for testing |
|
const mode = SERVE_MODE; |
|
const fileName = path.basename(req.path); |
|
if (await passthroughServeModeCdn(res, fileName)) { |
|
return; |
|
} |
|
if (mode == 'default') { |
|
req.url = req.url.replace(/\.(m?js)$/, '.max.$1'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/dist/iframe-transport-client-lib.(m?js)', (req, _res, next) => { |
|
req.url = req.url.replace(/dist/, 'dist.3p/current'); |
|
next(); |
|
}); |
|
|
|
app.get('/dist/amp-inabox-host.(m?js)', (req, _res, next) => { |
|
const mode = SERVE_MODE; |
|
if (mode != 'default') { |
|
req.url = req.url.replace('amp-inabox-host', 'amp4ads-host-v0'); |
|
} |
|
next(); |
|
}); |
|
|
|
app.get('/mraid.js', (req, _res, next) => { |
|
req.url = req.url.replace('mraid.js', 'examples/mraid/mraid.js'); |
|
next(); |
|
}); |
|
|
|
/** |
|
* Shadow viewer. Fetches shadow runtime from cdn by default. |
|
* Setting the param useLocal=1 will load the runtime from the local build. |
|
*/ |
|
app.use('/shadow/', (req, res) => { |
|
const {url} = req; |
|
const isProxyUrl = /^\/proxy\//.test(url); |
|
|
|
const baseHref = isProxyUrl |
|
? 'https://cdn.ampproject.org/' |
|
: `${path.dirname(url)}/`; |
|
|
|
const viewerHtml = renderShadowViewer({ |
|
src: '//' + req.hostname + '/' + req.url.replace(/^\//, ''), |
|
baseHref, |
|
}); |
|
|
|
if (!req.query.useLocal) { |
|
res.end(viewerHtml); |
|
return; |
|
} |
|
res.end(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
app.use('/mraid/', (req, res) => { |
|
res.redirect(req.url + '?inabox=1&mraid=1'); |
|
}); |
|
|
|
/** |
|
* @param {string} ampJsVersionString |
|
* @param {string} file |
|
* @return {string} |
|
*/ |
|
function addViewerIntegrationScript(ampJsVersionString, file) { |
|
const ampJsVersion = parseFloat(ampJsVersionString); |
|
if (!ampJsVersion) { |
|
return file; |
|
} |
|
let viewerScript; |
|
// eslint-disable-next-line local/no-es2015-number-props |
|
if (Number.isInteger(ampJsVersion)) { |
|
// Viewer integration script from gws, such as |
|
// https://cdn.ampproject.org/viewer/google/v7.js |
|
viewerScript = |
|
'<script async src="https://cdn.ampproject.org/viewer/google/v' + |
|
ampJsVersion + |
|
'.js"></script>'; |
|
} else { |
|
// Viewer integration script from runtime, such as |
|
// https://cdn.ampproject.org/v0/amp-viewer-integration-0.1.js |
|
viewerScript = |
|
'<script async ' + |
|
'src="https://cdn.ampproject.org/v0/amp-viewer-integration-' + |
|
ampJsVersion + |
|
'.js" data-amp-report-test="viewer-integr.js"></script>'; |
|
} |
|
file = file.replace('</head>', viewerScript + '</head>'); |
|
return file; |
|
} |
|
|
|
/** |
|
* @param {express.Request} req |
|
* @return {string} |
|
*/ |
|
function getUrlPrefix(req) { |
|
return req.protocol + '://' + req.headers.host; |
|
} |
|
|
|
/** |
|
* @param {string} filePath |
|
* @return {string} |
|
*/ |
|
function generateInfo(filePath) { |
|
const mode = SERVE_MODE; |
|
filePath = filePath.substr(0, filePath.length - 9) + '.html'; |
|
|
|
return ( |
|
'<h2>Please note that .min/.max is no longer supported</h2>' + |
|
'<h3>Current serving mode is ' + |
|
mode + |
|
'</h3>' + |
|
'<h3>Please go to <a href= ' + |
|
filePath + |
|
'>Unversioned Link</a> to view the page<h3>' + |
|
'<h3></h3>' + |
|
'<h3><a href = /serve_mode=default>' + |
|
'Change to DEFAULT mode (unminified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=minified>' + |
|
'Change to COMPILED mode (minified JS)</a></h3>' + |
|
'<h3><a href = /serve_mode=cdn>' + |
|
'Change to CDN mode (prod JS)</a></h3>' |
|
); |
|
} |
|
|
|
/** |
|
* @param {string} encryptedDocumentKey |
|
* @return {?string} |
|
*/ |
|
function decryptDocumentKey(encryptedDocumentKey) { |
|
if (!encryptedDocumentKey) { |
|
return null; |
|
} |
|
const cryptoStart = 'ENCRYPT('; |
|
if (!encryptedDocumentKey.includes(cryptoStart, 0)) { |
|
return null; |
|
} |
|
let jsonString = encryptedDocumentKey.replace(cryptoStart, ''); |
|
jsonString = jsonString.substring(0, jsonString.length - 1); |
|
const parsedJson = JSON.parse(jsonString); |
|
if (!parsedJson) { |
|
return null; |
|
} |
|
return parsedJson.key; |
|
} |
|
|
|
// serve local vendor config JSON files |
|
app.use( |
|
'(/dist)?/rtv/*/v0/analytics-vendors/:vendor.json', |
|
async (req, res) => { |
|
const {vendor} = req.params; |
|
const serveMode = SERVE_MODE; |
|
|
|
const cdnUrl = `https://cdn.ampproject.org/v0/analytics-vendors/${vendor}.json`; |
|
if (await passthroughServeModeCdn(res, cdnUrl)) { |
|
return; |
|
} |
|
|
|
const max = serveMode === 'default' ? '.max' : ''; |
|
const localPath = `${pc.cwd()}/dist/v0/analytics-vendors/${vendor}${max}.json`; |
|
|
|
try { |
|
const file = await fs.promises.readFile(localPath); |
|
res.setHeader('Content-Type', 'application/json'); |
|
res.end(file); |
|
} catch (_) { |
|
res.status(404); |
|
res.end('Not found: ' + localPath); |
|
} |
|
} |
|
); |
|
|
|
module.exports = app; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
amp4test.js:126
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [WITHDRAW]: ' + key); |
|
const result = bank[req.params.bid][key]; |
|
if (typeof result === 'function') { |
|
return res |
1 Data Flow/s detected
|
app.use('/request-bank/:bid/withdraw/:id/', (req, res) => { |
|
const key = req.params.id; |
|
.send(`another client is withdrawing this ID [${key}]`); |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
const app = require('express').Router(); |
|
const cors = require('./amp-cors'); |
|
const minimist = require('minimist'); |
|
const argv = minimist(process.argv.slice(2)); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const {getServeMode, replaceUrls} = require('./app-utils'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
const CUSTOM_TEMPLATES = ['amp-mustache']; |
|
const SERVE_MODE = getServeMode(); |
|
|
|
/** |
|
* Logs the given messages to the console when --verbose is specified. |
|
* @param {*} messages |
|
*/ |
|
function log(...messages) { |
|
if (argv.verbose) { |
|
console.log.apply(console, messages); |
|
} |
|
} |
|
|
|
app.use('/compose-doc', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
|
|
const {body, css, experiments, extensions, spec} = req.query; |
|
|
|
const frameHtml = |
|
SERVE_MODE == 'minified' |
|
? 'dist.3p/current-min/frame.html' |
|
: 'dist.3p/current/frame.max.html'; |
|
|
|
let experimentsBlock = ''; |
|
if (experiments) { |
|
const string = `"${experiments.split(',').join('","')}"`; |
|
// TODO: Why is setting localDev necessary? |
|
// `allow-doc-opt-in` enables any experiment to be enabled via doc opt-in. |
|
experimentsBlock = `<script> |
|
window.AMP_CONFIG = window.AMP_CONFIG || {"localDev": true}; |
|
window.AMP_CONFIG['allow-doc-opt-in'] = (window.AMP_CONFIG['allow-doc-opt-in'] || []).concat([${string}]); |
|
</script> |
|
<meta name="amp-experiments-opt-in" content="${experiments}">`; |
|
} |
|
|
|
// TODO: Do we need to inject amp-3p-iframe-src for non-ad tests? |
|
const head = `${experimentsBlock} |
|
<meta name="amp-3p-iframe-src" content="http://localhost:9876/${frameHtml}">`; |
|
|
|
const doc = composeDocument({ |
|
body, |
|
css, |
|
extensions: extensions ? extensions.split(',') : '', |
|
head, |
|
spec, |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
app.use('/compose-html', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
res.send(` |
|
<!doctype html> |
|
<html> |
|
<head> |
|
<title>NON-AMP TEST</title> |
|
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
</head> |
|
<body> |
|
${req.query.body} |
|
</body> |
|
</html> |
|
`); |
|
}); |
|
|
|
app.use('/compose-shadow', function (req, res) { |
|
const {docUrl} = req.query; |
|
const viewerHtml = renderShadowViewer({ |
|
src: docUrl.replace(/^\//, ''), |
|
baseHref: path.dirname(req.url), |
|
}); |
|
res.send(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
/** |
|
* A server side temporary request storage which is useful for testing |
|
* browser sent HTTP requests. |
|
*/ |
|
const bank = {}; |
|
|
|
/** |
|
* Deposit a request. An ID has to be specified. Will override previous request |
|
* if the same ID already exists. |
|
*/ |
|
app.use('/request-bank/:bid/deposit/:id/', upload.array(), (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [DEPOSIT]: ', key); |
|
if (typeof bank[req.params.bid][key] === 'function') { |
|
bank[req.params.bid][key](req); |
|
} else { |
|
bank[req.params.bid][key] = req; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Withdraw a request. If the request of the given ID is already in the bank, |
|
* return it immediately. Otherwise wait until it gets deposited |
|
* The same request cannot be withdrawn twice at the same time. |
|
*/ |
|
app.use('/request-bank/:bid/withdraw/:id/', (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [WITHDRAW]: ' + key); |
|
const result = bank[req.params.bid][key]; |
|
if (typeof result === 'function') { |
|
return res |
|
.status(500) |
|
@@ -128,1 +129,1 @@ |
|
- .send(`another client is withdrawing this ID [${key}]`); |
|
+ .send(`another client is withdrawing this ID [${escape(key)}]`); |
|
} |
|
const callback = function (result) { |
|
if (result === undefined) { |
|
// This happens when tearDown is called but no request |
|
// of given ID has been received yet. |
|
@@ -134,1 +135,1 @@ |
|
- res.status(404).send(`Request of given ID not found: [${key}]`); |
|
+ res.status(404).send(`Request of given ID not found: [${escape(key)}]`); |
|
} else { |
|
res.json({ |
|
headers: result.headers, |
|
body: result.body, |
|
url: result.url, |
|
}); |
|
} |
|
delete bank[req.params.bid][key]; |
|
}; |
|
if (result) { |
|
callback(result); |
|
} else { |
|
bank[req.params.bid][key] = callback; |
|
} |
|
}); |
|
|
|
/** |
|
* Clean up all pending withdraw & deposit requests. |
|
*/ |
|
app.use('/request-bank/:bid/teardown/', (req, res) => { |
|
log('SERVER-LOG [TEARDOWN]'); |
|
const b = bank[req.params.bid]; |
|
for (const id in b) { |
|
const callback = b[id]; |
|
if (typeof callback === 'function') { |
|
// Respond 404 to pending request. |
|
callback(); |
|
} |
|
delete b[id]; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Serves a fake ad for test-amp-ad-fake.js |
|
*/ |
|
app.get('/a4a/:bid', (req, res) => { |
|
cors.enableCors(req, res); |
|
const {bid} = req.params; |
|
const body = ` |
|
<a href=https://amp.dev target=_blank> |
|
<amp-img alt="AMP Ad" height=250 src=//localhost:9876/amp4test/request-bank/${bid}/deposit/image width=300></amp-img> |
|
</a> |
|
<amp-pixel src="//localhost:9876/amp4test/request-bank/${bid}/deposit/pixel/foo?cid=CLIENT_ID(a)"></amp-pixel> |
|
<amp-analytics> |
|
<script type="application/json"> |
|
{ |
|
"requests": { |
|
"pageview": "//localhost:9876/amp4test/request-bank/${bid}/deposit/analytics/bar" |
|
}, |
|
"triggers": { |
|
"pageview": { |
|
"on": "visible", |
|
"request": "pageview", |
|
"extraUrlParams": { |
|
"timestamp": "\${timestamp}", |
|
"title": "\${title}", |
|
"ampdocUrl": "\${ampdocUrl}", |
|
"canonicalUrl": "\${canonicalUrl}", |
|
"cid": "\${clientId(a)}", |
|
"img": "\${htmlAttr(amp-img,src)}", |
|
"navTiming": "\${navTiming(requestStart,requestStart)}", |
|
"navType": "\${navType}", |
|
"navRedirectCount": "\${navRedirectCount}", |
|
"sourceUrl": "\${sourceUrl}", |
|
"cookie": "\${cookie(test-cookie)}" |
|
} |
|
} |
|
} |
|
} |
|
</script> |
|
</amp-analytics>`; |
|
|
|
const doc = composeDocument({ |
|
spec: 'amp4ads', |
|
body, |
|
css: 'body { background-color: #f4f4f4; }', |
|
extensions: ['amp-analytics'], |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
/** |
|
* @param {{body: string, css?: string|undefined, extensions: Array<string>|undefined, head?: string|undefined, spec?: string|undefined, mode?: string|undefined}} config |
|
* @return {string} |
|
*/ |
|
function composeDocument(config) { |
|
const {body, css, extensions, head, mode, spec} = config; |
|
|
|
const m = mode || SERVE_MODE; |
|
const cdn = m === 'cdn'; |
|
const minified = m === 'minified'; |
|
|
|
const cssTag = css ? `<style amp-custom>${css}</style>` : ''; |
|
|
|
// Set link[rel=canonical], CSS boilerplate and runtime <script> depending |
|
// on the AMP spec. |
|
let canonical, boilerplate, runtime; |
|
const amp = spec || 'amp'; |
|
switch (amp) { |
|
case 'amp': |
|
canonical = '<link rel="canonical" href="http://nonblocking.io" />'; |
|
boilerplate = |
|
'<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
case 'amp4ads': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4ads-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/amp4ads-v0.js' |
|
: `/dist/${minified ? 'amp4ads-v0' : 'amp-inabox'}.js`; |
|
break; |
|
case 'amp4email': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4email-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
default: |
|
throw new Error('Unrecognized AMP spec: ' + spec); |
|
} |
|
const runtimeScript = `<script async src="${runtime}"></script>`; |
|
|
|
// Generate extension <script> markup. |
|
let extensionScripts = ''; |
|
if (extensions) { |
|
extensionScripts = extensions |
|
.map((extension) => { |
|
const tuple = extension.split(':'); |
|
const name = tuple[0]; |
|
const version = tuple[1] || '0.1'; |
|
const src = cdn |
|
? `https://cdn.ampproject.org/v0/${name}-${version}.js` |
|
: `/dist/v0/${name}-${version}.${minified ? '' : 'max.'}js`; |
|
const type = CUSTOM_TEMPLATES.includes(name) |
|
? 'custom-template' |
|
: 'custom-element'; |
|
return `<script async ${type}="${name}" src="${src}"></script>`; |
|
}) |
|
.join('\n'); |
|
} |
|
|
|
const topHalfOfHtml = `<!doctype html> |
|
<html ${amp}> |
|
<head> |
|
<title>AMP TEST</title> |
|
<meta charset="utf-8"> |
|
${canonical} |
|
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
${head || ''} |
|
${boilerplate} |
|
${runtimeScript} |
|
${extensionScripts} |
|
${cssTag} |
|
</head>`; |
|
|
|
// To enable A4A FIE, a <script amp-ad-metadata> tag must exist. |
|
let ampAdMeta = ''; |
|
if (amp === 'amp4ads') { |
|
// `ampRuntimeUtf16CharOffsets` is used to cut out all runtime scripts, |
|
// which are not needed in FIE mode. |
|
const start = topHalfOfHtml.indexOf(runtimeScript); |
|
let end = start + runtimeScript.length; |
|
|
|
let customElements = [], |
|
extensionsMap = []; |
|
if (extensions) { |
|
end = topHalfOfHtml.indexOf(extensionScripts) + extensionScripts.length; |
|
// Filter out extensions that are not custom elements, e.g. amp-mustache. |
|
customElements = extensions.filter((e) => !CUSTOM_TEMPLATES.includes(e)); |
|
extensionsMap = customElements.map((ce) => { |
|
return { |
|
'custom-element': ce, |
|
// TODO: Should this be a local URL i.e. /dist/v0/...? |
|
'src': `https://cdn.ampproject.org/v0/${ce}-0.1.js`, |
|
}; |
|
}); |
|
} |
|
ampAdMeta = `<script amp-ad-metadata type=application/json> |
|
{ |
|
"ampRuntimeUtf16CharOffsets": [ ${start}, ${end} ], |
|
"customElementExtensions": ${JSON.stringify(customElements)}, |
|
"extensions": ${JSON.stringify(extensionsMap)} |
|
} |
|
</script>`; |
|
} |
|
|
|
return `${topHalfOfHtml} |
|
<body> |
|
${body} |
|
${ampAdMeta} |
|
</body> |
|
</html>`; |
|
} |
|
|
|
module.exports = { |
|
app, |
|
log, |
|
}; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
amp4test.js:64
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
res.send(doc); |
|
}); |
|
|
|
app.use('/compose-html', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
res.send(` |
1 Data Flow/s detected
|
app.use('/compose-html', function (req, res) { |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
const app = require('express').Router(); |
|
const cors = require('./amp-cors'); |
|
const minimist = require('minimist'); |
|
const argv = minimist(process.argv.slice(2)); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const {getServeMode, replaceUrls} = require('./app-utils'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
const CUSTOM_TEMPLATES = ['amp-mustache']; |
|
const SERVE_MODE = getServeMode(); |
|
|
|
/** |
|
* Logs the given messages to the console when --verbose is specified. |
|
* @param {*} messages |
|
*/ |
|
function log(...messages) { |
|
if (argv.verbose) { |
|
console.log.apply(console, messages); |
|
} |
|
} |
|
|
|
app.use('/compose-doc', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
|
|
const {body, css, experiments, extensions, spec} = req.query; |
|
|
|
const frameHtml = |
|
SERVE_MODE == 'minified' |
|
? 'dist.3p/current-min/frame.html' |
|
: 'dist.3p/current/frame.max.html'; |
|
|
|
let experimentsBlock = ''; |
|
if (experiments) { |
|
const string = `"${experiments.split(',').join('","')}"`; |
|
// TODO: Why is setting localDev necessary? |
|
// `allow-doc-opt-in` enables any experiment to be enabled via doc opt-in. |
|
experimentsBlock = `<script> |
|
window.AMP_CONFIG = window.AMP_CONFIG || {"localDev": true}; |
|
window.AMP_CONFIG['allow-doc-opt-in'] = (window.AMP_CONFIG['allow-doc-opt-in'] || []).concat([${string}]); |
|
</script> |
|
<meta name="amp-experiments-opt-in" content="${experiments}">`; |
|
} |
|
|
|
// TODO: Do we need to inject amp-3p-iframe-src for non-ad tests? |
|
const head = `${experimentsBlock} |
|
<meta name="amp-3p-iframe-src" content="http://localhost:9876/${frameHtml}">`; |
|
|
|
const doc = composeDocument({ |
|
body, |
|
css, |
|
extensions: extensions ? extensions.split(',') : '', |
|
head, |
|
spec, |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
app.use('/compose-html', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
@@ -64,1 +65,1 @@ |
|
- res.send(` |
|
+ res.send(` |
|
<!doctype html> |
|
<html> |
|
<head> |
|
@@ -68,2 +69,2 @@ |
|
- <title>NON-AMP TEST</title> |
|
- <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
+<title>NON-AMP TEST</title> |
|
+<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
</head> |
|
<body> |
|
@@ -72,1 +73,1 @@ |
|
-${req.query.body} |
|
+${escape(req.query.body)} |
|
</body> |
|
</html> |
|
@@ -75,1 +76,1 @@ |
|
- `); |
|
+`); |
|
}); |
|
|
|
app.use('/compose-shadow', function (req, res) { |
|
const {docUrl} = req.query; |
|
const viewerHtml = renderShadowViewer({ |
|
src: docUrl.replace(/^\//, ''), |
|
baseHref: path.dirname(req.url), |
|
}); |
|
res.send(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
/** |
|
* A server side temporary request storage which is useful for testing |
|
* browser sent HTTP requests. |
|
*/ |
|
const bank = {}; |
|
|
|
/** |
|
* Deposit a request. An ID has to be specified. Will override previous request |
|
* if the same ID already exists. |
|
*/ |
|
app.use('/request-bank/:bid/deposit/:id/', upload.array(), (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [DEPOSIT]: ', key); |
|
if (typeof bank[req.params.bid][key] === 'function') { |
|
bank[req.params.bid][key](req); |
|
} else { |
|
bank[req.params.bid][key] = req; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Withdraw a request. If the request of the given ID is already in the bank, |
|
* return it immediately. Otherwise wait until it gets deposited |
|
* The same request cannot be withdrawn twice at the same time. |
|
*/ |
|
app.use('/request-bank/:bid/withdraw/:id/', (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [WITHDRAW]: ' + key); |
|
const result = bank[req.params.bid][key]; |
|
if (typeof result === 'function') { |
|
return res |
|
.status(500) |
|
.send(`another client is withdrawing this ID [${key}]`); |
|
} |
|
const callback = function (result) { |
|
if (result === undefined) { |
|
// This happens when tearDown is called but no request |
|
// of given ID has been received yet. |
|
res.status(404).send(`Request of given ID not found: [${key}]`); |
|
} else { |
|
res.json({ |
|
headers: result.headers, |
|
body: result.body, |
|
url: result.url, |
|
}); |
|
} |
|
delete bank[req.params.bid][key]; |
|
}; |
|
if (result) { |
|
callback(result); |
|
} else { |
|
bank[req.params.bid][key] = callback; |
|
} |
|
}); |
|
|
|
/** |
|
* Clean up all pending withdraw & deposit requests. |
|
*/ |
|
app.use('/request-bank/:bid/teardown/', (req, res) => { |
|
log('SERVER-LOG [TEARDOWN]'); |
|
const b = bank[req.params.bid]; |
|
for (const id in b) { |
|
const callback = b[id]; |
|
if (typeof callback === 'function') { |
|
// Respond 404 to pending request. |
|
callback(); |
|
} |
|
delete b[id]; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Serves a fake ad for test-amp-ad-fake.js |
|
*/ |
|
app.get('/a4a/:bid', (req, res) => { |
|
cors.enableCors(req, res); |
|
const {bid} = req.params; |
|
const body = ` |
|
<a href=https://amp.dev target=_blank> |
|
<amp-img alt="AMP Ad" height=250 src=//localhost:9876/amp4test/request-bank/${bid}/deposit/image width=300></amp-img> |
|
</a> |
|
<amp-pixel src="//localhost:9876/amp4test/request-bank/${bid}/deposit/pixel/foo?cid=CLIENT_ID(a)"></amp-pixel> |
|
<amp-analytics> |
|
<script type="application/json"> |
|
{ |
|
"requests": { |
|
"pageview": "//localhost:9876/amp4test/request-bank/${bid}/deposit/analytics/bar" |
|
}, |
|
"triggers": { |
|
"pageview": { |
|
"on": "visible", |
|
"request": "pageview", |
|
"extraUrlParams": { |
|
"timestamp": "\${timestamp}", |
|
"title": "\${title}", |
|
"ampdocUrl": "\${ampdocUrl}", |
|
"canonicalUrl": "\${canonicalUrl}", |
|
"cid": "\${clientId(a)}", |
|
"img": "\${htmlAttr(amp-img,src)}", |
|
"navTiming": "\${navTiming(requestStart,requestStart)}", |
|
"navType": "\${navType}", |
|
"navRedirectCount": "\${navRedirectCount}", |
|
"sourceUrl": "\${sourceUrl}", |
|
"cookie": "\${cookie(test-cookie)}" |
|
} |
|
} |
|
} |
|
} |
|
</script> |
|
</amp-analytics>`; |
|
|
|
const doc = composeDocument({ |
|
spec: 'amp4ads', |
|
body, |
|
css: 'body { background-color: #f4f4f4; }', |
|
extensions: ['amp-analytics'], |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
/** |
|
* @param {{body: string, css?: string|undefined, extensions: Array<string>|undefined, head?: string|undefined, spec?: string|undefined, mode?: string|undefined}} config |
|
* @return {string} |
|
*/ |
|
function composeDocument(config) { |
|
const {body, css, extensions, head, mode, spec} = config; |
|
|
|
const m = mode || SERVE_MODE; |
|
const cdn = m === 'cdn'; |
|
const minified = m === 'minified'; |
|
|
|
const cssTag = css ? `<style amp-custom>${css}</style>` : ''; |
|
|
|
// Set link[rel=canonical], CSS boilerplate and runtime <script> depending |
|
// on the AMP spec. |
|
let canonical, boilerplate, runtime; |
|
const amp = spec || 'amp'; |
|
switch (amp) { |
|
case 'amp': |
|
canonical = '<link rel="canonical" href="http://nonblocking.io" />'; |
|
boilerplate = |
|
'<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
case 'amp4ads': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4ads-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/amp4ads-v0.js' |
|
: `/dist/${minified ? 'amp4ads-v0' : 'amp-inabox'}.js`; |
|
break; |
|
case 'amp4email': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4email-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
default: |
|
throw new Error('Unrecognized AMP spec: ' + spec); |
|
} |
|
const runtimeScript = `<script async src="${runtime}"></script>`; |
|
|
|
// Generate extension <script> markup. |
|
let extensionScripts = ''; |
|
if (extensions) { |
|
extensionScripts = extensions |
|
.map((extension) => { |
|
const tuple = extension.split(':'); |
|
const name = tuple[0]; |
|
const version = tuple[1] || '0.1'; |
|
const src = cdn |
|
? `https://cdn.ampproject.org/v0/${name}-${version}.js` |
|
: `/dist/v0/${name}-${version}.${minified ? '' : 'max.'}js`; |
|
const type = CUSTOM_TEMPLATES.includes(name) |
|
? 'custom-template' |
|
: 'custom-element'; |
|
return `<script async ${type}="${name}" src="${src}"></script>`; |
|
}) |
|
.join('\n'); |
|
} |
|
|
|
const topHalfOfHtml = `<!doctype html> |
|
<html ${amp}> |
|
<head> |
|
<title>AMP TEST</title> |
|
<meta charset="utf-8"> |
|
${canonical} |
|
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
${head || ''} |
|
${boilerplate} |
|
${runtimeScript} |
|
${extensionScripts} |
|
${cssTag} |
|
</head>`; |
|
|
|
// To enable A4A FIE, a <script amp-ad-metadata> tag must exist. |
|
let ampAdMeta = ''; |
|
if (amp === 'amp4ads') { |
|
// `ampRuntimeUtf16CharOffsets` is used to cut out all runtime scripts, |
|
// which are not needed in FIE mode. |
|
const start = topHalfOfHtml.indexOf(runtimeScript); |
|
let end = start + runtimeScript.length; |
|
|
|
let customElements = [], |
|
extensionsMap = []; |
|
if (extensions) { |
|
end = topHalfOfHtml.indexOf(extensionScripts) + extensionScripts.length; |
|
// Filter out extensions that are not custom elements, e.g. amp-mustache. |
|
customElements = extensions.filter((e) => !CUSTOM_TEMPLATES.includes(e)); |
|
extensionsMap = customElements.map((ce) => { |
|
return { |
|
'custom-element': ce, |
|
// TODO: Should this be a local URL i.e. /dist/v0/...? |
|
'src': `https://cdn.ampproject.org/v0/${ce}-0.1.js`, |
|
}; |
|
}); |
|
} |
|
ampAdMeta = `<script amp-ad-metadata type=application/json> |
|
{ |
|
"ampRuntimeUtf16CharOffsets": [ ${start}, ${end} ], |
|
"customElementExtensions": ${JSON.stringify(customElements)}, |
|
"extensions": ${JSON.stringify(extensionsMap)} |
|
} |
|
</script>`; |
|
} |
|
|
|
return `${topHalfOfHtml} |
|
<body> |
|
${body} |
|
${ampAdMeta} |
|
</body> |
|
</html>`; |
|
} |
|
|
|
module.exports = { |
|
app, |
|
log, |
|
}; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|
|
High | Cross-Site Scripting |
CWE-79
|
amp4test.js:134
| 1 | 2024-08-19 04:08am |
Vulnerable Code
|
} |
|
const callback = function (result) { |
|
if (result === undefined) { |
|
// This happens when tearDown is called but no request |
|
// of given ID has been received yet. |
|
res.status(404).send(`Request of given ID not found: [${key}]`); |
1 Data Flow/s detected
|
app.use('/request-bank/:bid/withdraw/:id/', (req, res) => { |
|
const key = req.params.id; |
|
const callback = function (result) { |
|
res.status(404).send(`Request of given ID not found: [${key}]`); |
⛑️ Remediation Suggestion
|
--- original |
|
+++ remediated |
|
@@ -1,0 +1,1 @@ |
|
+const escape = require('escape-html'); |
|
'use strict'; |
|
|
|
const app = require('express').Router(); |
|
const cors = require('./amp-cors'); |
|
const minimist = require('minimist'); |
|
const argv = minimist(process.argv.slice(2)); |
|
const path = require('path'); |
|
const upload = require('multer')(); |
|
const {getServeMode, replaceUrls} = require('./app-utils'); |
|
const {renderShadowViewer} = require('./shadow-viewer'); |
|
|
|
const CUSTOM_TEMPLATES = ['amp-mustache']; |
|
const SERVE_MODE = getServeMode(); |
|
|
|
/** |
|
* Logs the given messages to the console when --verbose is specified. |
|
* @param {*} messages |
|
*/ |
|
function log(...messages) { |
|
if (argv.verbose) { |
|
console.log.apply(console, messages); |
|
} |
|
} |
|
|
|
app.use('/compose-doc', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
|
|
const {body, css, experiments, extensions, spec} = req.query; |
|
|
|
const frameHtml = |
|
SERVE_MODE == 'minified' |
|
? 'dist.3p/current-min/frame.html' |
|
: 'dist.3p/current/frame.max.html'; |
|
|
|
let experimentsBlock = ''; |
|
if (experiments) { |
|
const string = `"${experiments.split(',').join('","')}"`; |
|
// TODO: Why is setting localDev necessary? |
|
// `allow-doc-opt-in` enables any experiment to be enabled via doc opt-in. |
|
experimentsBlock = `<script> |
|
window.AMP_CONFIG = window.AMP_CONFIG || {"localDev": true}; |
|
window.AMP_CONFIG['allow-doc-opt-in'] = (window.AMP_CONFIG['allow-doc-opt-in'] || []).concat([${string}]); |
|
</script> |
|
<meta name="amp-experiments-opt-in" content="${experiments}">`; |
|
} |
|
|
|
// TODO: Do we need to inject amp-3p-iframe-src for non-ad tests? |
|
const head = `${experimentsBlock} |
|
<meta name="amp-3p-iframe-src" content="http://localhost:9876/${frameHtml}">`; |
|
|
|
const doc = composeDocument({ |
|
body, |
|
css, |
|
extensions: extensions ? extensions.split(',') : '', |
|
head, |
|
spec, |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
app.use('/compose-html', function (req, res) { |
|
res.setHeader('X-XSS-Protection', '0'); |
|
res.send(` |
|
<!doctype html> |
|
<html> |
|
<head> |
|
<title>NON-AMP TEST</title> |
|
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
</head> |
|
<body> |
|
${req.query.body} |
|
</body> |
|
</html> |
|
`); |
|
}); |
|
|
|
app.use('/compose-shadow', function (req, res) { |
|
const {docUrl} = req.query; |
|
const viewerHtml = renderShadowViewer({ |
|
src: docUrl.replace(/^\//, ''), |
|
baseHref: path.dirname(req.url), |
|
}); |
|
res.send(replaceUrls(SERVE_MODE, viewerHtml)); |
|
}); |
|
|
|
/** |
|
* A server side temporary request storage which is useful for testing |
|
* browser sent HTTP requests. |
|
*/ |
|
const bank = {}; |
|
|
|
/** |
|
* Deposit a request. An ID has to be specified. Will override previous request |
|
* if the same ID already exists. |
|
*/ |
|
app.use('/request-bank/:bid/deposit/:id/', upload.array(), (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [DEPOSIT]: ', key); |
|
if (typeof bank[req.params.bid][key] === 'function') { |
|
bank[req.params.bid][key](req); |
|
} else { |
|
bank[req.params.bid][key] = req; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Withdraw a request. If the request of the given ID is already in the bank, |
|
* return it immediately. Otherwise wait until it gets deposited |
|
* The same request cannot be withdrawn twice at the same time. |
|
*/ |
|
app.use('/request-bank/:bid/withdraw/:id/', (req, res) => { |
|
cors.enableCors(req, res); |
|
if (!bank[req.params.bid]) { |
|
bank[req.params.bid] = {}; |
|
} |
|
const key = req.params.id; |
|
log('SERVER-LOG [WITHDRAW]: ' + key); |
|
const result = bank[req.params.bid][key]; |
|
if (typeof result === 'function') { |
|
return res |
|
.status(500) |
|
@@ -128,1 +129,1 @@ |
|
- .send(`another client is withdrawing this ID [${key}]`); |
|
+ .send(`another client is withdrawing this ID [${escape(key)}]`); |
|
} |
|
const callback = function (result) { |
|
if (result === undefined) { |
|
// This happens when tearDown is called but no request |
|
// of given ID has been received yet. |
|
@@ -134,1 +135,1 @@ |
|
- res.status(404).send(`Request of given ID not found: [${key}]`); |
|
+ res.status(404).send(`Request of given ID not found: [${escape(key)}]`); |
|
} else { |
|
res.json({ |
|
headers: result.headers, |
|
body: result.body, |
|
url: result.url, |
|
}); |
|
} |
|
delete bank[req.params.bid][key]; |
|
}; |
|
if (result) { |
|
callback(result); |
|
} else { |
|
bank[req.params.bid][key] = callback; |
|
} |
|
}); |
|
|
|
/** |
|
* Clean up all pending withdraw & deposit requests. |
|
*/ |
|
app.use('/request-bank/:bid/teardown/', (req, res) => { |
|
log('SERVER-LOG [TEARDOWN]'); |
|
const b = bank[req.params.bid]; |
|
for (const id in b) { |
|
const callback = b[id]; |
|
if (typeof callback === 'function') { |
|
// Respond 404 to pending request. |
|
callback(); |
|
} |
|
delete b[id]; |
|
} |
|
res.end(); |
|
}); |
|
|
|
/** |
|
* Serves a fake ad for test-amp-ad-fake.js |
|
*/ |
|
app.get('/a4a/:bid', (req, res) => { |
|
cors.enableCors(req, res); |
|
const {bid} = req.params; |
|
const body = ` |
|
<a href=https://amp.dev target=_blank> |
|
<amp-img alt="AMP Ad" height=250 src=//localhost:9876/amp4test/request-bank/${bid}/deposit/image width=300></amp-img> |
|
</a> |
|
<amp-pixel src="//localhost:9876/amp4test/request-bank/${bid}/deposit/pixel/foo?cid=CLIENT_ID(a)"></amp-pixel> |
|
<amp-analytics> |
|
<script type="application/json"> |
|
{ |
|
"requests": { |
|
"pageview": "//localhost:9876/amp4test/request-bank/${bid}/deposit/analytics/bar" |
|
}, |
|
"triggers": { |
|
"pageview": { |
|
"on": "visible", |
|
"request": "pageview", |
|
"extraUrlParams": { |
|
"timestamp": "\${timestamp}", |
|
"title": "\${title}", |
|
"ampdocUrl": "\${ampdocUrl}", |
|
"canonicalUrl": "\${canonicalUrl}", |
|
"cid": "\${clientId(a)}", |
|
"img": "\${htmlAttr(amp-img,src)}", |
|
"navTiming": "\${navTiming(requestStart,requestStart)}", |
|
"navType": "\${navType}", |
|
"navRedirectCount": "\${navRedirectCount}", |
|
"sourceUrl": "\${sourceUrl}", |
|
"cookie": "\${cookie(test-cookie)}" |
|
} |
|
} |
|
} |
|
} |
|
</script> |
|
</amp-analytics>`; |
|
|
|
const doc = composeDocument({ |
|
spec: 'amp4ads', |
|
body, |
|
css: 'body { background-color: #f4f4f4; }', |
|
extensions: ['amp-analytics'], |
|
}); |
|
res.cookie('test-cookie', 'test'); |
|
res.send(doc); |
|
}); |
|
|
|
/** |
|
* @param {{body: string, css?: string|undefined, extensions: Array<string>|undefined, head?: string|undefined, spec?: string|undefined, mode?: string|undefined}} config |
|
* @return {string} |
|
*/ |
|
function composeDocument(config) { |
|
const {body, css, extensions, head, mode, spec} = config; |
|
|
|
const m = mode || SERVE_MODE; |
|
const cdn = m === 'cdn'; |
|
const minified = m === 'minified'; |
|
|
|
const cssTag = css ? `<style amp-custom>${css}</style>` : ''; |
|
|
|
// Set link[rel=canonical], CSS boilerplate and runtime <script> depending |
|
// on the AMP spec. |
|
let canonical, boilerplate, runtime; |
|
const amp = spec || 'amp'; |
|
switch (amp) { |
|
case 'amp': |
|
canonical = '<link rel="canonical" href="http://nonblocking.io" />'; |
|
boilerplate = |
|
'<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
case 'amp4ads': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4ads-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/amp4ads-v0.js' |
|
: `/dist/${minified ? 'amp4ads-v0' : 'amp-inabox'}.js`; |
|
break; |
|
case 'amp4email': |
|
canonical = ''; |
|
boilerplate = |
|
'<style amp4email-boilerplate>body{visibility:hidden}</style>'; |
|
runtime = cdn |
|
? 'https://cdn.ampproject.org/v0.js' |
|
: `/dist/${minified ? 'v0' : 'amp'}.js`; |
|
break; |
|
default: |
|
throw new Error('Unrecognized AMP spec: ' + spec); |
|
} |
|
const runtimeScript = `<script async src="${runtime}"></script>`; |
|
|
|
// Generate extension <script> markup. |
|
let extensionScripts = ''; |
|
if (extensions) { |
|
extensionScripts = extensions |
|
.map((extension) => { |
|
const tuple = extension.split(':'); |
|
const name = tuple[0]; |
|
const version = tuple[1] || '0.1'; |
|
const src = cdn |
|
? `https://cdn.ampproject.org/v0/${name}-${version}.js` |
|
: `/dist/v0/${name}-${version}.${minified ? '' : 'max.'}js`; |
|
const type = CUSTOM_TEMPLATES.includes(name) |
|
? 'custom-template' |
|
: 'custom-element'; |
|
return `<script async ${type}="${name}" src="${src}"></script>`; |
|
}) |
|
.join('\n'); |
|
} |
|
|
|
const topHalfOfHtml = `<!doctype html> |
|
<html ${amp}> |
|
<head> |
|
<title>AMP TEST</title> |
|
<meta charset="utf-8"> |
|
${canonical} |
|
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> |
|
${head || ''} |
|
${boilerplate} |
|
${runtimeScript} |
|
${extensionScripts} |
|
${cssTag} |
|
</head>`; |
|
|
|
// To enable A4A FIE, a <script amp-ad-metadata> tag must exist. |
|
let ampAdMeta = ''; |
|
if (amp === 'amp4ads') { |
|
// `ampRuntimeUtf16CharOffsets` is used to cut out all runtime scripts, |
|
// which are not needed in FIE mode. |
|
const start = topHalfOfHtml.indexOf(runtimeScript); |
|
let end = start + runtimeScript.length; |
|
|
|
let customElements = [], |
|
extensionsMap = []; |
|
if (extensions) { |
|
end = topHalfOfHtml.indexOf(extensionScripts) + extensionScripts.length; |
|
// Filter out extensions that are not custom elements, e.g. amp-mustache. |
|
customElements = extensions.filter((e) => !CUSTOM_TEMPLATES.includes(e)); |
|
extensionsMap = customElements.map((ce) => { |
|
return { |
|
'custom-element': ce, |
|
// TODO: Should this be a local URL i.e. /dist/v0/...? |
|
'src': `https://cdn.ampproject.org/v0/${ce}-0.1.js`, |
|
}; |
|
}); |
|
} |
|
ampAdMeta = `<script amp-ad-metadata type=application/json> |
|
{ |
|
"ampRuntimeUtf16CharOffsets": [ ${start}, ${end} ], |
|
"customElementExtensions": ${JSON.stringify(customElements)}, |
|
"extensions": ${JSON.stringify(extensionsMap)} |
|
} |
|
</script>`; |
|
} |
|
|
|
return `${topHalfOfHtml} |
|
<body> |
|
${body} |
|
${ampAdMeta} |
|
</body> |
|
</html>`; |
|
} |
|
|
|
module.exports = { |
|
app, |
|
log, |
|
}; |
Remediation feedback:
Secure Code Warrior Training Material
● Training
▪ Secure Code Warrior Cross-Site Scripting Training
● Videos
▪ Secure Code Warrior Cross-Site Scripting Video
|