From 5225d83b5eed54c7bd6adc297a4b0e49b3ecd3d6 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Mon, 22 Jun 2026 20:18:17 +0200 Subject: [PATCH 1/7] Alpha --- .../fxa-content-server/grunttasks/build.js | 4 + .../grunttasks/waict-manifest.js | 122 ++++++++++++++++++ .../server/bin/fxa-content-server.js | 22 +++- .../server/lib/configuration.js | 32 +++++ .../fxa-content-server/server/lib/routes.js | 10 ++ .../server/lib/routes/get-waict-manifest.js | 52 ++++++++ .../server/lib/routes/post-waict-report.js | 74 +++++++++++ .../fxa-content-server/server/lib/waict.js | 58 +++++++++ 8 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 packages/fxa-content-server/grunttasks/waict-manifest.js create mode 100644 packages/fxa-content-server/server/lib/routes/get-waict-manifest.js create mode 100644 packages/fxa-content-server/server/lib/routes/post-waict-report.js create mode 100644 packages/fxa-content-server/server/lib/waict.js diff --git a/packages/fxa-content-server/grunttasks/build.js b/packages/fxa-content-server/grunttasks/build.js index 132ad25d808..c22d85a4fd4 100644 --- a/packages/fxa-content-server/grunttasks/build.js +++ b/packages/fxa-content-server/grunttasks/build.js @@ -88,5 +88,9 @@ module.exports = function (grunt) { // copy fxa-settings. note this has already been webpacked. we don't need // run it through webpack again. 'copy:settings', + + // generate the WAICT integrity manifest. Must be last so it hashes both + // the content-server bundles and the copied fxa-settings bundles. + 'waict-manifest', ]); }; diff --git a/packages/fxa-content-server/grunttasks/waict-manifest.js b/packages/fxa-content-server/grunttasks/waict-manifest.js new file mode 100644 index 00000000000..c4c132b6af0 --- /dev/null +++ b/packages/fxa-content-server/grunttasks/waict-manifest.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Generate the WAICT integrity manifest from the built artifacts. The manifest +// maps every first-party script's served URL to the SHA-256 hash of its bytes. +// +// This must run at the very end of the build (after `copy:settings`) so that +// `dist` contains both the content-server bundles and the copied fxa-settings +// bundles - WAICT report mode covers the whole origin's scripts. +// +// See https://github.com/waict-wg/waict-integrity-spec. + +'use strict'; +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +module.exports = function (grunt) { + // Frontend unit-test bundles are only emitted in development and are not + // part of any shipped page. + const TEST_BUNDLE = /\/(test|testDependencies)\.bundle(\.|\b)/; + + /** + * Determine which fxa-settings build under dist/settings is actually served + * at /settings. Content-server serves `static_settings_directory` (default + * `prod`), but a given build only emits one env (e.g. a dev build produces + * dist/settings/dev). Honor the env var if set, otherwise use the single + * built directory, falling back to the server config default. + * + * @param {String} dist absolute path to the dist directory + * @returns {String} + */ + function resolveSettingsDirectory(dist) { + if (process.env.STATIC_SETTINGS_DIRECTORY) { + return process.env.STATIC_SETTINGS_DIRECTORY; + } + + let envDirs = []; + try { + const settingsRoot = path.join(dist, 'settings'); + envDirs = fs + .readdirSync(settingsRoot) + .filter((entry) => + fs.statSync(path.join(settingsRoot, entry)).isDirectory() + ); + } catch (e) { + // No dist/settings directory; nothing to resolve. + } + + return envDirs.length === 1 ? envDirs[0] : 'prod'; + } + + /** + * Map a dist-relative path to the URL the browser requests it from, or + * return null if the file is not served. + * + * @param {String} distRelative forward-slash path relative to dist + * @param {String} settingsDirectory the served fxa-settings env directory + * @returns {String|null} + */ + function toServedUrl(distRelative, settingsDirectory) { + if (distRelative.indexOf('settings/') === 0) { + // settings// -> /settings/, but only for the served env. + const withoutPrefix = distRelative.slice('settings/'.length); + const slash = withoutPrefix.indexOf('/'); + if (slash === -1) { + return null; + } + const env = withoutPrefix.slice(0, slash); + if (env !== settingsDirectory) { + return null; + } + return '/settings/' + withoutPrefix.slice(slash + 1); + } + + return '/' + distRelative; + } + + grunt.registerTask( + 'waict-manifest', + 'Generate the WAICT integrity manifest of served script hashes', + function () { + const dist = grunt.config.get('yeoman.dist'); + const settingsDirectory = resolveSettingsDirectory(dist); + const hashes = {}; + let count = 0; + + grunt.file + .expand({ cwd: dist }, '**/*.js') + .forEach(function (relative) { + const distRelative = relative.split(path.sep).join('/'); + if (TEST_BUNDLE.test('/' + distRelative)) { + return; + } + + const servedUrl = toServedUrl(distRelative, settingsDirectory); + if (!servedUrl) { + return; + } + + const bytes = grunt.file.read(path.join(dist, relative), { + encoding: null, + }); + // WAICT v1 always uses SHA-256, base64-encoded (matching SRI's + // `sha256-` convention but without the algorithm prefix). + hashes[servedUrl] = crypto + .createHash('sha256') + .update(bytes) + .digest('base64'); + count++; + }); + + const manifest = { hashes }; + const dest = path.join(dist, 'waict-manifest.json'); + grunt.file.write(dest, JSON.stringify(manifest, null, 2)); + grunt.log.writeln( + 'Wrote WAICT manifest with ' + count + ' script hashes to ' + dest + ); + } + ); +}; diff --git a/packages/fxa-content-server/server/bin/fxa-content-server.js b/packages/fxa-content-server/server/bin/fxa-content-server.js index b7327264d43..316628ef331 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -70,6 +70,7 @@ const csp = require('../lib/csp'); const cspRulesBlocking = require('../lib/csp/blocking')(config); const cspRulesReportOnly = require('../lib/csp/report-only')(config); const coop = require('../lib/coop'); +const waict = require('../lib/waict'); const glean = require('../lib/glean')(config.getProperties()); const STATIC_DIRECTORY = path.join( @@ -148,6 +149,17 @@ function makeApp() { } app.use(coop()); + if (config.get('waict.enabled')) { + app.use( + waict({ + manifestPath: config.get('waict.manifestPath'), + maxAge: config.get('waict.maxAge'), + blockedDestinations: config.get('waict.blockedDestinations'), + reportUri: config.get('waict.reportUri'), + }) + ); + } + app.disable('x-powered-by'); app.use(routeLogging()); @@ -164,11 +176,17 @@ function makeApp() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1192840 app.use( bodyParser.json({ - // the 3 entries: + // the entries: // json file types, // all json content-types // csp reports - type: ['json', '*/json', 'application/csp-report'], + // WAICT / Reporting API violation reports + type: [ + 'json', + '*/json', + 'application/csp-report', + 'application/reports+json', + ], }) ); app.use( diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 15e3768f19f..9492299fecc 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -141,6 +141,38 @@ const conf = (module.exports = convict({ format: Array, }, }, + waict: { + enabled: { + default: false, + doc: 'Send the "Integrity-Policy-WAICT-v1" header in report mode', + env: 'WAICT_ENABLED', + format: Boolean, + }, + manifestPath: { + default: '/waict-manifest.json', + doc: 'URL path the WAICT integrity manifest is served from', + env: 'WAICT_MANIFEST_PATH', + format: String, + }, + maxAge: { + default: 0, + doc: 'Value of the WAICT header "max-age" directive, in seconds', + env: 'WAICT_MAX_AGE', + format: 'nat', + }, + blockedDestinations: { + default: ['script'], + doc: 'Fetch destinations the WAICT manifest is enforced against', + env: 'WAICT_BLOCKED_DESTINATIONS', + format: Array, + }, + reportUri: { + default: '/_/waict-violation', + doc: 'Location reports for WAICT violations are sent to', + env: 'WAICT_REPORT_URI', + format: String, + }, + }, disable_locale_check: { default: false, doc: 'Skip checking for gettext .mo files for supported locales', diff --git a/packages/fxa-content-server/server/lib/routes.js b/packages/fxa-content-server/server/lib/routes.js index 37a01d67090..2834d715628 100644 --- a/packages/fxa-content-server/server/lib/routes.js +++ b/packages/fxa-content-server/server/lib/routes.js @@ -62,6 +62,16 @@ module.exports = function (config, i18n, statsd, glean) { ); } + if (config.get('waict.enabled')) { + routes.push(require('./routes/get-waict-manifest')(config)); + routes.push( + require('./routes/post-waict-report')({ + op: 'server.waict.violation', + path: config.get('waict.reportUri'), + }) + ); + } + if (config.get('env') === 'development') { routes.push(require('./routes/get-502')(config)); routes.push(require('./routes/get-503')(config)); diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js new file mode 100644 index 00000000000..0b927f24f49 --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Serve the WAICT integrity manifest. The manifest maps served script URLs to + * their SHA-256 hashes and is generated at build time (see the `waict-manifest` + * grunt task) into the static directory. It is served with the + * `application/waict-integrity-manifest` content-type the WAICT spec requires. + */ + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const logger = require('../logging/log')(); + +const MANIFEST_CONTENT_TYPE = 'application/waict-integrity-manifest'; + +module.exports = function (config) { + // Mirror the STATIC_DIRECTORY resolution in server/bin/fxa-content-server.js, + // adjusted for this file's location (server/lib/routes). + const manifestFile = path.join( + __dirname, + '..', + '..', + '..', + config.get('static_directory'), + 'waict-manifest.json' + ); + + return { + method: 'get', + path: config.get('waict.manifestPath'), + process: function (req, res) { + fs.readFile(manifestFile, (err, body) => { + if (err) { + // The manifest is produced by the build; if it is missing the page + // still works (report mode is non-blocking) so log and 404 rather + // than erroring the request. + logger.warn('waict.manifest.missing', { path: manifestFile }); + res.status(404).end(); + return; + } + + res.type(MANIFEST_CONTENT_TYPE); + res.send(body); + }); + }, + }; +}; + +module.exports.MANIFEST_CONTENT_TYPE = MANIFEST_CONTENT_TYPE; diff --git a/packages/fxa-content-server/server/lib/routes/post-waict-report.js b/packages/fxa-content-server/server/lib/routes/post-waict-report.js new file mode 100644 index 00000000000..f5680e4651a --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Collect WAICT integrity violation reports. + * + * In report mode the browser does not block anything; it sends `waict-violation` + * reports here via the Reporting API so we can find scripts whose hashes are + * missing from, or do not match, the manifest. The Reporting API delivers a + * JSON array of reports with content-type `application/reports+json`. + */ + +'use strict'; +const logger = require('../logging/log')(); +const url = require('url'); + +function stripPIIFromUrl(urlToScrub) { + if (!urlToScrub || typeof urlToScrub !== 'string') { + return ''; + } + + let parsedUrl; + try { + parsedUrl = url.parse(urlToScrub, true); + } catch (e) { + return ''; + } + + if (!parsedUrl.query.email && !parsedUrl.query.uid) { + return urlToScrub; + } + + delete parsedUrl.query.email; + delete parsedUrl.query.uid; + delete parsedUrl.search; + + return url.format(parsedUrl); +} + +module.exports = function (options = {}) { + return { + method: 'post', + path: options.path, + process: function (req, res) { + // Acknowledge immediately; reports are best-effort telemetry. + res.json({ success: true }); + + // The Reporting API sends an array of reports; older/other delivery may + // send a single object. Normalize to an array. + const reports = Array.isArray(req.body) ? req.body : [req.body]; + + reports.forEach((report) => { + if (!report || typeof report !== 'object') { + return; + } + + const body = report.body || {}; + logger.info(options.op, { + agent: req.get('User-Agent'), + type: report.type, + // The resource that failed integrity and the reason (e.g. + // missing_from_manifest, no_manifest_match, invalid_manifest). + reason: body.reason, + blocked: stripPIIFromUrl(body.blockedURL || body.blocked_url), + documentURL: stripPIIFromUrl( + body.documentURL || body.documentURI || report.url + ), + destination: body.destination, + }); + }); + }, + }; +}; diff --git a/packages/fxa-content-server/server/lib/waict.js b/packages/fxa-content-server/server/lib/waict.js new file mode 100644 index 00000000000..326019929b8 --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Middleware that emits the WAICT (Web Application Integrity, Consistency and +// Transparency) `Integrity-Policy-WAICT-v1` response header in *report* mode. +// In report mode the browser only logs and reports integrity violations - it +// never blocks or alters resource loading - so it is safe to ship while we +// validate manifest coverage. Headers are only sent when `waict.enabled` is +// set (default false), and only on HTML document responses. +// +// See https://github.com/waict-wg/waict-integrity-spec and Firefox bug 2017652. + +'use strict'; +const htmlOnly = require('./html-middleware'); + +// The report endpoint is advertised under this name in both the +// `Reporting-Endpoints` header and the WAICT header's `endpoints` parameter. +const REPORT_ENDPOINT_NAME = 'waict'; + +/** + * Build the `Integrity-Policy-WAICT-v1` structured-field header value. + * + * @param {Object} config waict configuration + * @returns {String} + */ +function buildHeaderValue(config) { + // `blocked-destinations` is a structured-field inner list of tokens, e.g. + // `(script style)`. Scoping to `script` limits coverage to JavaScript. + const destinations = config.blockedDestinations.join(' '); + + // `manifest` is an sf-string and must be quoted. `mode=report` is what makes + // this non-blocking. `max-age` of 0 means downgrade protection is not pinned, + // which is appropriate while iterating in report mode. + return [ + `max-age=${config.maxAge}`, + 'mode=report', + `blocked-destinations=(${destinations})`, + `endpoints=(${REPORT_ENDPOINT_NAME})`, + `manifest="${config.manifestPath}"`, + ].join(', '); +} + +module.exports = function (config) { + const headerValue = buildHeaderValue(config); + const reportingEndpoints = `${REPORT_ENDPOINT_NAME}="${config.reportUri}"`; + + return htmlOnly((req, res, next) => { + // `Reporting-Endpoints` maps the `endpoints` name to a collection URL so + // the browser knows where to POST `waict-violation` reports. + res.setHeader('Reporting-Endpoints', reportingEndpoints); + res.setHeader('Integrity-Policy-WAICT-v1', headerValue); + next(); + }); +}; + +module.exports.buildHeaderValue = buildHeaderValue; +module.exports.REPORT_ENDPOINT_NAME = REPORT_ENDPOINT_NAME; From c10e34638784a95022e00d2d921623717391e354 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Mon, 22 Jun 2026 20:18:17 +0200 Subject: [PATCH 2/7] Alpha --- .../server/bin/fxa-content-server.js | 1 + .../server/lib/configuration.js | 6 ++++ .../fxa-content-server/server/lib/routes.js | 4 +++ .../server/lib/routes/get-waict-canary.js | 30 +++++++++++++++++++ .../server/lib/routes/post-waict-report.js | 12 ++++++++ .../fxa-content-server/server/lib/waict.js | 7 +++++ 6 files changed, 60 insertions(+) create mode 100644 packages/fxa-content-server/server/lib/routes/get-waict-canary.js diff --git a/packages/fxa-content-server/server/bin/fxa-content-server.js b/packages/fxa-content-server/server/bin/fxa-content-server.js index 316628ef331..9249a5b84c2 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -156,6 +156,7 @@ function makeApp() { maxAge: config.get('waict.maxAge'), blockedDestinations: config.get('waict.blockedDestinations'), reportUri: config.get('waict.reportUri'), + statsd, }) ); } diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 9492299fecc..d54853cb4a5 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -172,6 +172,12 @@ const conf = (module.exports = convict({ env: 'WAICT_REPORT_URI', format: String, }, + canaryEnabled: { + default: false, + doc: 'Serve a canary script deliberately absent from the manifest to verify the reporting pipeline is live', + env: 'WAICT_CANARY_ENABLED', + format: Boolean, + }, }, disable_locale_check: { default: false, diff --git a/packages/fxa-content-server/server/lib/routes.js b/packages/fxa-content-server/server/lib/routes.js index 2834d715628..72d3b6d34d3 100644 --- a/packages/fxa-content-server/server/lib/routes.js +++ b/packages/fxa-content-server/server/lib/routes.js @@ -68,8 +68,12 @@ module.exports = function (config, i18n, statsd, glean) { require('./routes/post-waict-report')({ op: 'server.waict.violation', path: config.get('waict.reportUri'), + statsd, }) ); + if (config.get('waict.canaryEnabled')) { + routes.push(require('./routes/get-waict-canary')(config)); + } } if (config.get('env') === 'development') { diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-canary.js b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js new file mode 100644 index 00000000000..adac979ed7e --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Serve the WAICT canary script. The canary is deliberately absent from the + * integrity manifest, so referencing it from a page produces a guaranteed + * `missing_from_manifest` violation report on every load. A steady stream of + * canary reports proves the browser -> report-endpoint pipeline is live; + * their disappearance means reporting is broken, not that the origin is clean. + */ + +'use strict'; + +// Inert no-op. The script's only purpose is to be fetched as a `script` +// destination so WAICT checks it against the manifest and fails to match. +const CANARY_BODY = '/* waict canary */\n'; + +module.exports = function () { + return { + method: 'get', + path: '/waict-canary.js', + process: function (req, res) { + // Never cache, so every page load re-fetches and re-checks the canary. + res.setHeader('Cache-Control', 'no-store'); + res.type('application/javascript'); + res.send(CANARY_BODY); + }, + }; +}; diff --git a/packages/fxa-content-server/server/lib/routes/post-waict-report.js b/packages/fxa-content-server/server/lib/routes/post-waict-report.js index f5680e4651a..ed5965042b6 100644 --- a/packages/fxa-content-server/server/lib/routes/post-waict-report.js +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.js @@ -39,6 +39,8 @@ function stripPIIFromUrl(urlToScrub) { } module.exports = function (options = {}) { + const statsd = options.statsd; + return { method: 'post', path: options.path, @@ -56,6 +58,16 @@ module.exports = function (options = {}) { } const body = report.body || {}; + + // Emit an operational counter so violations are alertable as a + // time-series, tagged by reason (missing_from_manifest, + // no_manifest_match, invalid_manifest). + if (statsd) { + statsd.increment('waict.violation', 1, { + reason: body.reason || 'unknown', + }); + } + logger.info(options.op, { agent: req.get('User-Agent'), type: report.type, diff --git a/packages/fxa-content-server/server/lib/waict.js b/packages/fxa-content-server/server/lib/waict.js index 326019929b8..97ef961a0ff 100644 --- a/packages/fxa-content-server/server/lib/waict.js +++ b/packages/fxa-content-server/server/lib/waict.js @@ -44,12 +44,19 @@ function buildHeaderValue(config) { module.exports = function (config) { const headerValue = buildHeaderValue(config); const reportingEndpoints = `${REPORT_ENDPOINT_NAME}="${config.reportUri}"`; + const statsd = config.statsd; return htmlOnly((req, res, next) => { // `Reporting-Endpoints` maps the `endpoints` name to a collection URL so // the browser knows where to POST `waict-violation` reports. res.setHeader('Reporting-Endpoints', reportingEndpoints); res.setHeader('Integrity-Policy-WAICT-v1', headerValue); + + // Count every WAICT-protected document served. + if (statsd) { + statsd.increment('waict.document_served'); + } + next(); }); }; From cce67e4a224b88a7460c6da32dfa3e3b8650e143 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Wed, 24 Jun 2026 16:05:55 +0200 Subject: [PATCH 3/7] Update --- .../grunttasks/waict-manifest.js | 49 +++++++++++++++++-- .../server/lib/beta-settings.js | 45 ++++++++++++----- .../fxa-content-server/server/lib/routes.js | 9 +++- .../server/lib/routes/get-waict-canary.js | 8 ++- .../server/lib/routes/post-waict-report.js | 42 ++++++++++++++-- .../fxa-content-server/server/lib/waict.js | 4 +- packages/fxa-settings/scripts/build.js | 15 ++++++ 7 files changed, 150 insertions(+), 22 deletions(-) diff --git a/packages/fxa-content-server/grunttasks/waict-manifest.js b/packages/fxa-content-server/grunttasks/waict-manifest.js index c4c132b6af0..758fc2f27eb 100644 --- a/packages/fxa-content-server/grunttasks/waict-manifest.js +++ b/packages/fxa-content-server/grunttasks/waict-manifest.js @@ -84,8 +84,27 @@ module.exports = function (grunt) { const dist = grunt.config.get('yeoman.dist'); const settingsDirectory = resolveSettingsDirectory(dist); const hashes = {}; + const anyHashes = []; let count = 0; + // Declarations emitted by the fxa-settings build (scripts/build.js) for + // public/ scripts referenced with a volatile ?v= cache-buster, keyed by + // basename. toServedUrl below can't reconstruct that query, so these + // files are routed per their declared mode instead of the default key. + const publicAssets = (function () { + const sidecar = path.join( + dist, + 'settings', + settingsDirectory, + 'waict-public-assets.json' + ); + try { + return JSON.parse(fs.readFileSync(sidecar, 'utf8')); + } catch (e) { + return {}; + } + })(); + grunt.file .expand({ cwd: dist }, '**/*.js') .forEach(function (relative) { @@ -104,18 +123,42 @@ module.exports = function (grunt) { }); // WAICT v1 always uses SHA-256, base64-encoded (matching SRI's // `sha256-` convention but without the algorithm prefix). - hashes[servedUrl] = crypto + const hash = crypto .createHash('sha256') .update(bytes) .digest('base64'); + + // Route declared cache-busted public/ scripts. `any` matches the + // content hash regardless of URL (absorbs the ?v=); `exact` pins the + // precise ?v= URL the build references it with. + const basename = servedUrl.startsWith('/settings/') + ? servedUrl.slice('/settings/'.length) + : null; + const declared = basename && publicAssets[basename]; + if (declared) { + if (declared.mode === 'any') { + anyHashes.push(hash); + } else if (declared.mode === 'exact') { + hashes[servedUrl + '?v=' + declared.v] = hash; + } + count++; + return; + } + + hashes[servedUrl] = hash; count++; }); - const manifest = { hashes }; + const manifest = { hashes, any_hashes: anyHashes }; const dest = path.join(dist, 'waict-manifest.json'); grunt.file.write(dest, JSON.stringify(manifest, null, 2)); grunt.log.writeln( - 'Wrote WAICT manifest with ' + count + ' script hashes to ' + dest + 'Wrote WAICT manifest with ' + + count + + ' script hashes (' + + anyHashes.length + + ' url-agnostic) to ' + + dest ); } ); diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 344ff9e94b6..6cc0527e02f 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -171,6 +171,23 @@ function swapBetaMeta(html, tmplContent = {}) { return result; } +// The canary is deliberately absent from the WAICT integrity manifest, so +// referencing it from a page yields a guaranteed missing_from_manifest report +// on every load - proving the reporting pipeline is live. Served from the +// origin root by get-waict-canary.js; gated by the same config as the route. +const waictCanaryTag = + config.get('waict.enabled') && config.get('waict.canaryEnabled') + ? '' + : ''; + +// Inject the WAICT canary script before , if enabled. +function injectWaictCanary(html) { + if (!waictCanaryTag) { + return html; + } + return html.replace('', waictCanaryTag + ''); +} + const preconnectLinks = []; function preconnect(val) { if (!val) { @@ -235,12 +252,14 @@ function modifyProxyRes(proxyRes, req, res) { ) { let html = body.toString(); const flowEventData = flowMetrics.create(FLOW_ID_KEY); - html = swapBetaMeta(html, { - __SERVER_CONFIG__: settingsConfig, - __FLOW_ID__: flowEventData.flowId, - __FLOW_BEGIN_TIME__: flowEventData.flowBeginTime, - ...resolvePreConnectDirectives(settingsConfig), - }); + html = injectWaictCanary( + swapBetaMeta(html, { + __SERVER_CONFIG__: settingsConfig, + __FLOW_ID__: flowEventData.flowId, + __FLOW_BEGIN_TIME__: flowEventData.flowBeginTime, + ...resolvePreConnectDirectives(settingsConfig), + }) + ); res.send(new Buffer.from(html)); } else { // remove transfer-encoding header, a Content-Length header will be added @@ -284,12 +303,14 @@ const modifySettingsStatic = function (req, res) { const flowEventData = flowMetrics.create(FLOW_ID_KEY); return res.send( - swapBetaMeta(indexFile, { - __SERVER_CONFIG__: settingsConfig, - __FLOW_ID__: flowEventData.flowId, - __FLOW_BEGIN_TIME__: flowEventData.flowBeginTime, - ...resolvePreConnectDirectives(settingsConfig), - }) + injectWaictCanary( + swapBetaMeta(indexFile, { + __SERVER_CONFIG__: settingsConfig, + __FLOW_ID__: flowEventData.flowId, + __FLOW_BEGIN_TIME__: flowEventData.flowBeginTime, + ...resolvePreConnectDirectives(settingsConfig), + }) + ) ); }; diff --git a/packages/fxa-content-server/server/lib/routes.js b/packages/fxa-content-server/server/lib/routes.js index 72d3b6d34d3..b40f129e7ae 100644 --- a/packages/fxa-content-server/server/lib/routes.js +++ b/packages/fxa-content-server/server/lib/routes.js @@ -63,15 +63,22 @@ module.exports = function (config, i18n, statsd, glean) { } if (config.get('waict.enabled')) { + const canaryEnabled = config.get('waict.canaryEnabled'); routes.push(require('./routes/get-waict-manifest')(config)); routes.push( require('./routes/post-waict-report')({ op: 'server.waict.violation', + canaryOp: 'server.waict.canary.success', path: config.get('waict.reportUri'), + // When the canary is enabled, reports for it are a success signal + // (the reporting pipeline is alive), not a real integrity violation. + canaryPath: canaryEnabled + ? require('./routes/get-waict-canary').CANARY_PATH + : null, statsd, }) ); - if (config.get('waict.canaryEnabled')) { + if (canaryEnabled) { routes.push(require('./routes/get-waict-canary')(config)); } } diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-canary.js b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js index adac979ed7e..fe2ebd6643e 100644 --- a/packages/fxa-content-server/server/lib/routes/get-waict-canary.js +++ b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js @@ -16,10 +16,14 @@ // destination so WAICT checks it against the manifest and fails to match. const CANARY_BODY = '/* waict canary */\n'; +// URL the canary is served from. Exported so the report endpoint can recognize +// incoming canary violation reports and treat them as a pipeline-alive success. +const CANARY_PATH = '/waict-canary.js'; + module.exports = function () { return { method: 'get', - path: '/waict-canary.js', + path: CANARY_PATH, process: function (req, res) { // Never cache, so every page load re-fetches and re-checks the canary. res.setHeader('Cache-Control', 'no-store'); @@ -28,3 +32,5 @@ module.exports = function () { }, }; }; + +module.exports.CANARY_PATH = CANARY_PATH; diff --git a/packages/fxa-content-server/server/lib/routes/post-waict-report.js b/packages/fxa-content-server/server/lib/routes/post-waict-report.js index ed5965042b6..b891db87b25 100644 --- a/packages/fxa-content-server/server/lib/routes/post-waict-report.js +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.js @@ -38,8 +38,23 @@ function stripPIIFromUrl(urlToScrub) { return url.format(parsedUrl); } +// A report is from the canary if its blocked resource path matches the canary +// path (compared by pathname, ignoring origin and any query string). +function isCanaryReport(blockedUrl, canaryPath) { + if (!blockedUrl || typeof blockedUrl !== 'string' || !canaryPath) { + return false; + } + + try { + return url.parse(blockedUrl).pathname === canaryPath; + } catch (e) { + return false; + } +} + module.exports = function (options = {}) { const statsd = options.statsd; + const canaryPath = options.canaryPath; return { method: 'post', @@ -58,6 +73,27 @@ module.exports = function (options = {}) { } const body = report.body || {}; + const blockedUrl = body.blockedURL || body.blocked_url; + const documentURL = stripPIIFromUrl( + body.documentURL || body.documentURI || report.url + ); + + // The canary is expected to fail integrity on every load, so a report + // for it is a *success* signal: the browser -> report-endpoint pipeline + // is alive. Treat it as such rather than as a real integrity violation, + // so canary noise never pollutes violation alerting. + if (isCanaryReport(blockedUrl, canaryPath)) { + if (statsd) { + statsd.increment('waict.canary.success'); + } + + logger.info(options.canaryOp || 'server.waict.canary.success', { + agent: req.get('User-Agent'), + type: report.type, + documentURL, + }); + return; + } // Emit an operational counter so violations are alertable as a // time-series, tagged by reason (missing_from_manifest, @@ -74,10 +110,8 @@ module.exports = function (options = {}) { // The resource that failed integrity and the reason (e.g. // missing_from_manifest, no_manifest_match, invalid_manifest). reason: body.reason, - blocked: stripPIIFromUrl(body.blockedURL || body.blocked_url), - documentURL: stripPIIFromUrl( - body.documentURL || body.documentURI || report.url - ), + blocked: stripPIIFromUrl(blockedUrl), + documentURL, destination: body.destination, }); }); diff --git a/packages/fxa-content-server/server/lib/waict.js b/packages/fxa-content-server/server/lib/waict.js index 97ef961a0ff..65503583caa 100644 --- a/packages/fxa-content-server/server/lib/waict.js +++ b/packages/fxa-content-server/server/lib/waict.js @@ -16,7 +16,9 @@ const htmlOnly = require('./html-middleware'); // The report endpoint is advertised under this name in both the // `Reporting-Endpoints` header and the WAICT header's `endpoints` parameter. -const REPORT_ENDPOINT_NAME = 'waict'; +// Must be `default` - the Reporting API's reserved fallback endpoint, which is +// where WAICT violation reports are delivered. +const REPORT_ENDPOINT_NAME = 'default'; /** * Build the `Integrity-Policy-WAICT-v1` structured-field header value. diff --git a/packages/fxa-settings/scripts/build.js b/packages/fxa-settings/scripts/build.js index a3700b3d70d..d1948dee907 100644 --- a/packages/fxa-settings/scripts/build.js +++ b/packages/fxa-settings/scripts/build.js @@ -81,6 +81,13 @@ const hashFile = (name) => process.env.REACT_APP_QUERY_FIX_HASH = hashFile('query-fix.js'); process.env.REACT_APP_LANG_FIX_HASH = hashFile('lang-fix.js'); +// WAICT manifest hints. The lines above add a ?v= query to these scripts' +// URLs; `exact` pins that ?v= URL in `hashes`, `any` matches by content hash. +const WAICT_PUBLIC_ASSETS = { + 'lang-fix.js': { mode: 'exact', v: process.env.REACT_APP_LANG_FIX_HASH }, + 'query-fix.js': { mode: 'any' }, +}; + // These sizes are pretty large. We'll warn for bundles exceeding them. const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; @@ -145,6 +152,14 @@ checkBrowsers(paths.appPath, isInteractive) ); console.log(); + // Written after the build (emptyDirSync above would wipe it earlier) so + // copy:settings carries it into the content-server dist for WAICT + // manifest generation. + fs.writeFileSync( + path.join(paths.appBuild, 'waict-public-assets.json'), + JSON.stringify(WAICT_PUBLIC_ASSETS, null, 2) + ); + const appPackage = require(paths.appPackageJson); const publicUrl = paths.publicUrlOrPath; const publicPath = config.output.publicPath; From 93cd5d69ab09b90c9e9aff2a3f02e368fd2d3d8e Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Tue, 7 Jul 2026 18:36:19 +0200 Subject: [PATCH 4/7] Add WAICT tests --- packages/fxa-content-server/.eslintrc | 8 + .../grunttasks/waict-manifest.js | 128 +++---- packages/fxa-content-server/jest.config.js | 32 ++ packages/fxa-content-server/package.json | 4 +- .../server/bin/fxa-content-server.js | 8 +- .../server/lib/beta-settings.js | 17 +- .../lib/routes/get-waict-canary.test.js | 36 ++ .../server/lib/routes/get-waict-manifest.js | 9 +- .../lib/routes/get-waict-manifest.test.js | 78 +++++ .../server/lib/routes/post-csp.js | 29 +- .../server/lib/routes/post-waict-report.js | 185 +++++++---- .../lib/routes/post-waict-report.test.js | 313 ++++++++++++++++++ .../server/lib/static-paths.js | 45 +++ .../server/lib/url-scrubber.js | 54 +++ .../server/lib/url-scrubber.test.js | 60 ++++ .../server/lib/waict-manifest-builder.js | 197 +++++++++++ .../server/lib/waict-manifest-builder.test.js | 240 ++++++++++++++ .../server/lib/waict.test.js | 141 ++++++++ packages/fxa-settings/scripts/build.js | 11 +- 19 files changed, 1388 insertions(+), 207 deletions(-) create mode 100644 packages/fxa-content-server/jest.config.js create mode 100644 packages/fxa-content-server/server/lib/routes/get-waict-canary.test.js create mode 100644 packages/fxa-content-server/server/lib/routes/get-waict-manifest.test.js create mode 100644 packages/fxa-content-server/server/lib/routes/post-waict-report.test.js create mode 100644 packages/fxa-content-server/server/lib/static-paths.js create mode 100644 packages/fxa-content-server/server/lib/url-scrubber.js create mode 100644 packages/fxa-content-server/server/lib/url-scrubber.test.js create mode 100644 packages/fxa-content-server/server/lib/waict-manifest-builder.js create mode 100644 packages/fxa-content-server/server/lib/waict-manifest-builder.test.js create mode 100644 packages/fxa-content-server/server/lib/waict.test.js diff --git a/packages/fxa-content-server/.eslintrc b/packages/fxa-content-server/.eslintrc index a1965226b0f..28688b57abd 100644 --- a/packages/fxa-content-server/.eslintrc +++ b/packages/fxa-content-server/.eslintrc @@ -18,6 +18,14 @@ "strict": "off", "handle-callback-err": "off" }, + "overrides": [ + { + "files": ["**/*.test.js"], + "env": { + "jest": true + } + } + ], "ignorePatterns": [ "app/scripts/lib/glean/*.js", "dist", diff --git a/packages/fxa-content-server/grunttasks/waict-manifest.js b/packages/fxa-content-server/grunttasks/waict-manifest.js index 758fc2f27eb..445c289e068 100644 --- a/packages/fxa-content-server/grunttasks/waict-manifest.js +++ b/packages/fxa-content-server/grunttasks/waict-manifest.js @@ -9,24 +9,26 @@ // `dist` contains both the content-server bundles and the copied fxa-settings // bundles - WAICT report mode covers the whole origin's scripts. // +// This file is a thin grunt/filesystem adapter; the decision logic lives in +// ../server/lib/waict-manifest-builder.js (unit-tested there). +// // See https://github.com/waict-wg/waict-integrity-spec. 'use strict'; -const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); +const { + pickSettingsDirectory, + buildManifest, +} = require('../server/lib/waict-manifest-builder'); module.exports = function (grunt) { - // Frontend unit-test bundles are only emitted in development and are not - // part of any shipped page. - const TEST_BUNDLE = /\/(test|testDependencies)\.bundle(\.|\b)/; - /** * Determine which fxa-settings build under dist/settings is actually served * at /settings. Content-server serves `static_settings_directory` (default * `prod`), but a given build only emits one env (e.g. a dev build produces * dist/settings/dev). Honor the env var if set, otherwise use the single - * built directory, falling back to the server config default. + * built directory, warning if it can't be uniquely resolved. * * @param {String} dist absolute path to the dist directory * @returns {String} @@ -48,33 +50,17 @@ module.exports = function (grunt) { // No dist/settings directory; nothing to resolve. } - return envDirs.length === 1 ? envDirs[0] : 'prod'; - } - - /** - * Map a dist-relative path to the URL the browser requests it from, or - * return null if the file is not served. - * - * @param {String} distRelative forward-slash path relative to dist - * @param {String} settingsDirectory the served fxa-settings env directory - * @returns {String|null} - */ - function toServedUrl(distRelative, settingsDirectory) { - if (distRelative.indexOf('settings/') === 0) { - // settings// -> /settings/, but only for the served env. - const withoutPrefix = distRelative.slice('settings/'.length); - const slash = withoutPrefix.indexOf('/'); - if (slash === -1) { - return null; - } - const env = withoutPrefix.slice(0, slash); - if (env !== settingsDirectory) { - return null; - } - return '/settings/' + withoutPrefix.slice(slash + 1); + if (envDirs.length !== 1) { + grunt.log.error( + 'waict-manifest: could not uniquely resolve the served settings dir ' + + '(found ' + + envDirs.length + + '); defaulting to prod. Settings scripts may be omitted from the ' + + 'manifest if that is not the served env.' + ); } - return '/' + distRelative; + return pickSettingsDirectory(envDirs, undefined, 'prod'); } grunt.registerTask( @@ -83,15 +69,13 @@ module.exports = function (grunt) { function () { const dist = grunt.config.get('yeoman.dist'); const settingsDirectory = resolveSettingsDirectory(dist); - const hashes = {}; - const anyHashes = []; - let count = 0; - // Declarations emitted by the fxa-settings build (scripts/build.js) for - // public/ scripts referenced with a volatile ?v= cache-buster, keyed by - // basename. toServedUrl below can't reconstruct that query, so these - // files are routed per their declared mode instead of the default key. - const publicAssets = (function () { + // Hints emitted by the fxa-settings build (scripts/build.js), carried in + // by copy:settings. `baseUrl` is the origin settings scripts are served + // from (CDN for stage/prod, '' for same-origin dev); `assets` declares + // cache-busted public/ scripts by basename so their volatile ?v= URL is + // handled per its mode. Contract shared with waict-manifest-builder.js. + const { baseUrl: settingsBaseUrl, assets: publicAssets } = (function () { const sidecar = path.join( dist, 'settings', @@ -99,64 +83,38 @@ module.exports = function (grunt) { 'waict-public-assets.json' ); try { - return JSON.parse(fs.readFileSync(sidecar, 'utf8')); + const parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8')); + // New shape is { baseUrl, assets }; tolerate an older flat map. + if (parsed && parsed.assets) { + return { baseUrl: parsed.baseUrl || '', assets: parsed.assets }; + } + return { baseUrl: '', assets: parsed || {} }; } catch (e) { - return {}; + return { baseUrl: '', assets: {} }; } })(); - grunt.file + const files = grunt.file .expand({ cwd: dist }, '**/*.js') - .forEach(function (relative) { - const distRelative = relative.split(path.sep).join('/'); - if (TEST_BUNDLE.test('/' + distRelative)) { - return; - } - - const servedUrl = toServedUrl(distRelative, settingsDirectory); - if (!servedUrl) { - return; - } - - const bytes = grunt.file.read(path.join(dist, relative), { - encoding: null, - }); - // WAICT v1 always uses SHA-256, base64-encoded (matching SRI's - // `sha256-` convention but without the algorithm prefix). - const hash = crypto - .createHash('sha256') - .update(bytes) - .digest('base64'); - - // Route declared cache-busted public/ scripts. `any` matches the - // content hash regardless of URL (absorbs the ?v=); `exact` pins the - // precise ?v= URL the build references it with. - const basename = servedUrl.startsWith('/settings/') - ? servedUrl.slice('/settings/'.length) - : null; - const declared = basename && publicAssets[basename]; - if (declared) { - if (declared.mode === 'any') { - anyHashes.push(hash); - } else if (declared.mode === 'exact') { - hashes[servedUrl + '?v=' + declared.v] = hash; - } - count++; - return; - } - - hashes[servedUrl] = hash; - count++; - }); + .map((relative) => relative.split(path.sep).join('/')); + + const { manifest, count } = buildManifest({ + files, + readBytes: (distRelative) => + grunt.file.read(path.join(dist, distRelative), { encoding: null }), + settingsDirectory, + settingsBaseUrl, + publicAssets, + warn: (msg) => grunt.log.error(msg), + }); - const manifest = { hashes, any_hashes: anyHashes }; const dest = path.join(dist, 'waict-manifest.json'); grunt.file.write(dest, JSON.stringify(manifest, null, 2)); grunt.log.writeln( 'Wrote WAICT manifest with ' + count + ' script hashes (' + - anyHashes.length + + manifest.any_hashes.length + ' url-agnostic) to ' + dest ); diff --git a/packages/fxa-content-server/jest.config.js b/packages/fxa-content-server/jest.config.js new file mode 100644 index 00000000000..1dfccfbfdbd --- /dev/null +++ b/packages/fxa-content-server/jest.config.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +// Jest config for fxa-content-server server-side unit tests. The package's +// legacy (intern) test harness was removed; new server/lib tests are plain +// CommonJS and run here, matching the Jest convention used by sibling server +// packages (fxa-profile-server, fxa-auth-server, etc.). +module.exports = { + testEnvironment: 'node', + rootDir: '.', + // Co-located server-side unit tests only. App/browser code is covered by the + // functional (Playwright) suite, not here. + testMatch: ['/server/**/*.test.js'], + moduleFileExtensions: ['js', 'json'], + testPathIgnorePatterns: ['/node_modules/'], + testTimeout: 20000, + clearMocks: true, + // Coverage configuration (enabled via --coverage flag). + collectCoverageFrom: [ + 'server/lib/waict.js', + 'server/lib/waict-manifest-builder.js', + 'server/lib/url-scrubber.js', + 'server/lib/routes/get-waict-manifest.js', + 'server/lib/routes/get-waict-canary.js', + 'server/lib/routes/post-waict-report.js', + ], + coverageDirectory: '../../artifacts/coverage/fxa-content-server-jest', + coverageReporters: ['text', 'lcov', 'html'], +}; diff --git a/packages/fxa-content-server/package.json b/packages/fxa-content-server/package.json index e0f28f07037..224f010008a 100644 --- a/packages/fxa-content-server/package.json +++ b/packages/fxa-content-server/package.json @@ -24,7 +24,9 @@ "delete": "pm2 delete pm2.config.js", "start-production": "NODE_ENV=production grunt build && yarn build-css && CONFIG_FILES=server/config/local.json,server/config/production.json,server/config/secrets.json grunt serverproc:dist", "start-remote": "scripts/run_remote_dev.sh", - "format": "prettier --write --config ../../_dev/.prettierrc '**'" + "format": "prettier --write --config ../../_dev/.prettierrc '**'", + "test": "yarn test-unit", + "test-unit": "JEST_JUNIT_OUTPUT_FILE=../../artifacts/tests/$npm_package_name/fxa-content-server-jest-unit-results.xml NODE_ENV=test jest --forceExit --reporters=default --reporters=jest-junit" }, "repository": { "type": "git", diff --git a/packages/fxa-content-server/server/bin/fxa-content-server.js b/packages/fxa-content-server/server/bin/fxa-content-server.js index 9249a5b84c2..1bc3dcf1c8e 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -72,13 +72,9 @@ const cspRulesReportOnly = require('../lib/csp/report-only')(config); const coop = require('../lib/coop'); const waict = require('../lib/waict'); const glean = require('../lib/glean')(config.getProperties()); +const { staticDirectory } = require('../lib/static-paths'); -const STATIC_DIRECTORY = path.join( - __dirname, - '..', - '..', - config.get('static_directory') -); +const STATIC_DIRECTORY = staticDirectory(config); const PAGE_TEMPLATE_DIRECTORY = path.join( config.get('page_template_root'), diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 6cc0527e02f..3d351af91d0 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -12,21 +12,12 @@ const { const config = require('./configuration'); const FLOW_ID_KEY = config.get('flow_id_key'); const flowMetrics = require('./flow-metrics'); +const { settingsStaticDirectory } = require('./static-paths'); +const { CANARY_PATH } = require('./routes/get-waict-canary'); const env = config.get('env'); -const settingsStaticPath = (() => { - const static_directory = config.get('static_directory'); - const static_settings_directory = config.get('static_settings_directory'); - return join( - __dirname, - '..', - '..', - static_directory, - 'settings', - static_settings_directory - ); -})(); +const settingsStaticPath = settingsStaticDirectory(config); let settingsIndexFile; function getSettingsIndexFile() { @@ -177,7 +168,7 @@ function swapBetaMeta(html, tmplContent = {}) { // origin root by get-waict-canary.js; gated by the same config as the route. const waictCanaryTag = config.get('waict.enabled') && config.get('waict.canaryEnabled') - ? '' + ? `` : ''; // Inject the WAICT canary script before , if enabled. diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-canary.test.js b/packages/fxa-content-server/server/lib/routes/get-waict-canary.test.js new file mode 100644 index 00000000000..17a9a8421e5 --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-canary.test.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const getWaictCanary = require('./get-waict-canary'); + +describe('get-waict-canary route', () => { + it('is a GET route served from the exported canary path', () => { + const route = getWaictCanary(); + expect(route.method).toBe('get'); + expect(route.path).toBe(getWaictCanary.CANARY_PATH); + expect(getWaictCanary.CANARY_PATH).toBe('/waict-canary.js'); + }); + + it('serves an inert javascript body that is never cached', () => { + const route = getWaictCanary(); + const res = { + setHeader: jest.fn(), + type: jest.fn(), + send: jest.fn(), + }; + + route.process({}, res); + + // no-store guarantees every page load re-fetches and re-checks the canary. + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(res.type).toHaveBeenCalledWith('application/javascript'); + expect(res.send).toHaveBeenCalledTimes(1); + // The body is a harmless no-op comment; assert it is a non-empty string. + const body = res.send.mock.calls[0][0]; + expect(typeof body).toBe('string'); + expect(body.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js index 0b927f24f49..59a215c835b 100644 --- a/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js +++ b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js @@ -13,18 +13,13 @@ const fs = require('fs'); const path = require('path'); const logger = require('../logging/log')(); +const { staticDirectory } = require('../static-paths'); const MANIFEST_CONTENT_TYPE = 'application/waict-integrity-manifest'; module.exports = function (config) { - // Mirror the STATIC_DIRECTORY resolution in server/bin/fxa-content-server.js, - // adjusted for this file's location (server/lib/routes). const manifestFile = path.join( - __dirname, - '..', - '..', - '..', - config.get('static_directory'), + staticDirectory(config), 'waict-manifest.json' ); diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-manifest.test.js b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.test.js new file mode 100644 index 00000000000..e3dd5c75245 --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.test.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const fs = require('fs'); + +// Capture the logger instance the route obtains at require time so we can +// assert on it. The name must start with `mock` for jest's hoisting rules. +const mockLogger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; +jest.mock('../logging/log', () => () => mockLogger); +jest.mock('fs'); + +const getWaictManifest = require('./get-waict-manifest'); + +function mockConfig(overrides = {}) { + const values = { + static_directory: 'app/dist', + 'waict.manifestPath': '/waict-manifest.json', + ...overrides, + }; + return { get: (key) => values[key] }; +} + +function mockRes() { + const res = { + status: jest.fn(() => res), + end: jest.fn(() => res), + type: jest.fn(() => res), + send: jest.fn(() => res), + }; + return res; +} + +describe('get-waict-manifest route', () => { + it('is a GET route served from the configured manifest path', () => { + const route = getWaictManifest(mockConfig()); + expect(route.method).toBe('get'); + expect(route.path).toBe('/waict-manifest.json'); + }); + + it('serves the manifest with the WAICT content-type when present', () => { + const body = Buffer.from('{"hashes":{}}'); + fs.readFile.mockImplementation((file, cb) => cb(null, body)); + + const route = getWaictManifest(mockConfig()); + const res = mockRes(); + route.process({}, res); + + expect(res.type).toHaveBeenCalledWith( + getWaictManifest.MANIFEST_CONTENT_TYPE + ); + expect(getWaictManifest.MANIFEST_CONTENT_TYPE).toBe( + 'application/waict-integrity-manifest' + ); + expect(res.send).toHaveBeenCalledWith(body); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('404s (not 500s) and logs a warning when the manifest is missing', () => { + fs.readFile.mockImplementation((file, cb) => + cb(new Error('ENOENT'), null) + ); + + const route = getWaictManifest(mockConfig()); + const res = mockRes(); + route.process({}, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.end).toHaveBeenCalled(); + expect(res.send).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'waict.manifest.missing', + expect.objectContaining({ path: expect.any(String) }) + ); + }); +}); diff --git a/packages/fxa-content-server/server/lib/routes/post-csp.js b/packages/fxa-content-server/server/lib/routes/post-csp.js index 1ffe8738ad9..d31ad3eaeef 100644 --- a/packages/fxa-content-server/server/lib/routes/post-csp.js +++ b/packages/fxa-content-server/server/lib/routes/post-csp.js @@ -9,8 +9,8 @@ 'use strict'; const joi = require('joi'); const logger = require('../logging/log')(); -const url = require('url'); const validation = require('../validation'); +const { stripPIIFromUrl } = require('../url-scrubber'); const { overrideJoiMessages, } = require('fxa-shared/sentry/joi-message-overrides'); @@ -81,30 +81,3 @@ module.exports = function (options = {}) { }, }; }; - -function stripPIIFromUrl(urlToScrub) { - if (!urlToScrub || typeof urlToScrub !== 'string') { - return ''; - } - - let parsedUrl; - - try { - parsedUrl = url.parse(urlToScrub, true); - } catch (e) { - // failed to parse the given url - return ''; - } - - if (!parsedUrl.query.email && !parsedUrl.query.uid) { - return urlToScrub; - } - - delete parsedUrl.query.email; - delete parsedUrl.query.uid; - - // delete parsedUrl.search or else format returns the old querystring. - delete parsedUrl.search; - - return url.format(parsedUrl); -} diff --git a/packages/fxa-content-server/server/lib/routes/post-waict-report.js b/packages/fxa-content-server/server/lib/routes/post-waict-report.js index b891db87b25..d208170183f 100644 --- a/packages/fxa-content-server/server/lib/routes/post-waict-report.js +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.js @@ -9,34 +9,68 @@ * reports here via the Reporting API so we can find scripts whose hashes are * missing from, or do not match, the manifest. The Reporting API delivers a * JSON array of reports with content-type `application/reports+json`. + * + * The endpoint is unauthenticated by design (browsers POST reports with no + * credentials), so every field is attacker-controlled. Input is constrained by + * the joi `validate` block below - mirroring the sibling `post-csp.js` - which + * caps the array length, bounds string sizes, and strips unknown keys. Never + * trust these values: the `reason` metric tag is additionally allowlisted (see + * below) so an attacker cannot blow up metric cardinality. */ 'use strict'; +const joi = require('joi'); const logger = require('../logging/log')(); -const url = require('url'); - -function stripPIIFromUrl(urlToScrub) { - if (!urlToScrub || typeof urlToScrub !== 'string') { - return ''; - } - - let parsedUrl; - try { - parsedUrl = url.parse(urlToScrub, true); - } catch (e) { - return ''; - } - - if (!parsedUrl.query.email && !parsedUrl.query.uid) { - return urlToScrub; - } - - delete parsedUrl.query.email; - delete parsedUrl.query.uid; - delete parsedUrl.search; - - return url.format(parsedUrl); -} +const { URL } = require('url'); +const validation = require('../validation'); +const { stripPIIFromUrl } = require('../url-scrubber'); + +const STRING_TYPE = validation.TYPES.STRING; + +// Maximum reports accepted in a single POST. The Reporting API batches a small +// number of reports; anything beyond this is dropped by validation so a single +// request cannot be amplified into unbounded log/metric writes. +const MAX_REPORTS_PER_REQUEST = 100; + +// The integrity-check outcomes WAICT can report. Used to allowlist the `reason` +// StatsD tag so untrusted report bodies cannot create unbounded tag cardinality +// (a metrics-store DoS). Unknown values are bucketed under `other`. +const ALLOWED_REASONS = new Set([ + 'missing_from_manifest', + 'no_manifest_match', + 'invalid_manifest', +]); + +// A single Reporting API report. Only the fields we read are declared; celebrate +// is configured with stripUnknown for objects, so any other keys the browser +// sends are dropped rather than logged. +const REPORT_SCHEMA = joi.object().keys({ + type: STRING_TYPE.allow('').optional(), + // Top-level `url` is used as a fallback document URL by some report shapes. + url: STRING_TYPE.allow('').optional(), + body: joi + .object() + .keys({ + // Reporting API standard is camelCase; snake_case aliases are tolerated + // because the WAICT spec is still unstable. + blockedURL: STRING_TYPE.allow('').optional(), + blocked_url: STRING_TYPE.allow('').optional(), + documentURL: STRING_TYPE.allow('').optional(), + documentURI: STRING_TYPE.allow('').optional(), + reason: STRING_TYPE.allow('').optional(), + destination: STRING_TYPE.allow('').optional(), + }) + .optional(), +}); + +// The browser posts an array of reports; older/other delivery may post a single +// object. Accept either, capping the array length. +const BODY_SCHEMA = joi + .alternatives() + .try( + joi.array().items(REPORT_SCHEMA).max(MAX_REPORTS_PER_REQUEST), + REPORT_SCHEMA + ); // A report is from the canary if its blocked resource path matches the canary // path (compared by pathname, ignoring origin and any query string). @@ -46,7 +80,7 @@ function isCanaryReport(blockedUrl, canaryPath) { } try { - return url.parse(blockedUrl).pathname === canaryPath; + return new URL(blockedUrl).pathname === canaryPath; } catch (e) { return false; } @@ -59,6 +93,9 @@ module.exports = function (options = {}) { return { method: 'post', path: options.path, + validate: { + body: BODY_SCHEMA, + }, process: function (req, res) { // Acknowledge immediately; reports are best-effort telemetry. res.json({ success: true }); @@ -67,54 +104,72 @@ module.exports = function (options = {}) { // send a single object. Normalize to an array. const reports = Array.isArray(req.body) ? req.body : [req.body]; - reports.forEach((report) => { - if (!report || typeof report !== 'object') { - return; - } - - const body = report.body || {}; - const blockedUrl = body.blockedURL || body.blocked_url; - const documentURL = stripPIIFromUrl( - body.documentURL || body.documentURI || report.url - ); - - // The canary is expected to fail integrity on every load, so a report - // for it is a *success* signal: the browser -> report-endpoint pipeline - // is alive. Treat it as such rather than as a real integrity violation, - // so canary noise never pollutes violation alerting. - if (isCanaryReport(blockedUrl, canaryPath)) { + // Guard the whole loop: the response is already sent, so a throw here + // would otherwise reach the Express error handler on a finished response. + try { + reports.forEach((report) => { + if (!report || typeof report !== 'object') { + return; + } + + const body = report.body || {}; + const blockedUrl = body.blockedURL || body.blocked_url; + const documentURL = stripPIIFromUrl( + body.documentURL || body.documentURI || report.url + ); + + // The canary is expected to fail integrity on every load, so a report + // for it is a *success* signal: the browser -> report-endpoint + // pipeline is alive. Treat it as such rather than as a real integrity + // violation, so canary noise never pollutes violation alerting. + if (isCanaryReport(blockedUrl, canaryPath)) { + if (statsd) { + statsd.increment('waict.canary.success'); + } + + logger.info(options.canaryOp || 'server.waict.canary.success', { + agent: req.get('User-Agent'), + type: report.type, + documentURL, + }); + return; + } + + // Emit an operational counter so violations are alertable as a + // time-series, tagged by reason. The tag is allowlisted so untrusted + // report bodies cannot create unbounded metric cardinality. if (statsd) { - statsd.increment('waict.canary.success'); + statsd.increment('waict.violation', 1, { + reason: allowedReasonTag(body.reason), + }); } - logger.info(options.canaryOp || 'server.waict.canary.success', { + logger.info(options.op, { agent: req.get('User-Agent'), type: report.type, + // The resource that failed integrity and the reason (e.g. + // missing_from_manifest, no_manifest_match, invalid_manifest). + reason: body.reason, + blocked: stripPIIFromUrl(blockedUrl), documentURL, + destination: body.destination, }); - return; - } - - // Emit an operational counter so violations are alertable as a - // time-series, tagged by reason (missing_from_manifest, - // no_manifest_match, invalid_manifest). - if (statsd) { - statsd.increment('waict.violation', 1, { - reason: body.reason || 'unknown', - }); - } - - logger.info(options.op, { - agent: req.get('User-Agent'), - type: report.type, - // The resource that failed integrity and the reason (e.g. - // missing_from_manifest, no_manifest_match, invalid_manifest). - reason: body.reason, - blocked: stripPIIFromUrl(blockedUrl), - documentURL, - destination: body.destination, }); - }); + } catch (err) { + logger.warn('server.waict.report.error', { err: err && err.message }); + } }, }; }; + +// Map an untrusted `reason` to a bounded-cardinality StatsD tag value. +function allowedReasonTag(reason) { + if (!reason) { + return 'unknown'; + } + return ALLOWED_REASONS.has(reason) ? reason : 'other'; +} + +module.exports.BODY_SCHEMA = BODY_SCHEMA; +module.exports.MAX_REPORTS_PER_REQUEST = MAX_REPORTS_PER_REQUEST; +module.exports.ALLOWED_REASONS = ALLOWED_REASONS; diff --git a/packages/fxa-content-server/server/lib/routes/post-waict-report.test.js b/packages/fxa-content-server/server/lib/routes/post-waict-report.test.js new file mode 100644 index 00000000000..761b60fd68b --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.test.js @@ -0,0 +1,313 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +jest.mock('../logging/log', () => () => mockLogger); + +const postWaictReport = require('./post-waict-report'); + +const CANARY_PATH = '/waict-canary.js'; + +function build(overrides = {}) { + const statsd = { increment: jest.fn() }; + const route = postWaictReport({ + op: 'server.waict.violation', + canaryOp: 'server.waict.canary.success', + path: '/_/waict-violation', + statsd, + canaryPath: CANARY_PATH, + ...overrides, + }); + return { route, statsd }; +} + +function mockReqRes(body, userAgent = 'Firefox') { + const req = { + body, + get: jest.fn((h) => (h === 'User-Agent' ? userAgent : undefined)), + }; + const res = { json: jest.fn() }; + return { req, res }; +} + +// A well-formed WAICT violation report body (browser sends camelCase). +function violationReport(overrides = {}) { + return { + type: 'waict-violation', + body: { + blockedURL: 'https://accounts.firefox.com/scripts/app.js', + documentURL: 'https://accounts.firefox.com/signin', + reason: 'missing_from_manifest', + destination: 'script', + ...overrides, + }, + }; +} + +describe('post-waict-report route', () => { + it('is a POST route at the configured path', () => { + const { route } = build(); + expect(route.method).toBe('post'); + expect(route.path).toBe('/_/waict-violation'); + }); + + it('acknowledges the request immediately with success', () => { + const { route } = build(); + const { req, res } = mockReqRes([violationReport()]); + route.process(req, res); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('logs a violation and increments a counter tagged by reason', () => { + const { route, statsd } = build(); + const { req, res } = mockReqRes([violationReport()]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'missing_from_manifest', + }); + expect(mockLogger.info).toHaveBeenCalledWith( + 'server.waict.violation', + expect.objectContaining({ + reason: 'missing_from_manifest', + blocked: 'https://accounts.firefox.com/scripts/app.js', + destination: 'script', + }) + ); + }); + + it('defaults the statsd reason tag to "unknown" when reason is absent', () => { + const { route, statsd } = build(); + const report = violationReport(); + delete report.body.reason; + const { req, res } = mockReqRes([report]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'unknown', + }); + }); + + it('buckets an unknown reason under "other" to bound tag cardinality', () => { + const { route, statsd } = build(); + const report = violationReport({ reason: 'totally-made-up-reason' }); + const { req, res } = mockReqRes([report]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'other', + }); + // The raw reason is still logged (bounded by validation in production). + const logged = mockLogger.info.mock.calls.find( + (c) => c[0] === 'server.waict.violation' + )[1]; + expect(logged.reason).toBe('totally-made-up-reason'); + }); + + it('treats a canary report as a pipeline-alive success, not a violation', () => { + const { route, statsd } = build(); + const report = violationReport({ + blockedURL: `https://accounts.firefox.com${CANARY_PATH}`, + }); + const { req, res } = mockReqRes([report]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.canary.success'); + expect(statsd.increment).not.toHaveBeenCalledWith( + 'waict.violation', + expect.anything(), + expect.anything() + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'server.waict.canary.success', + expect.any(Object) + ); + }); + + it('matches the canary by pathname even with a query string', () => { + const { route, statsd } = build(); + const report = violationReport({ + blockedURL: `https://accounts.firefox.com${CANARY_PATH}?v=123`, + }); + const { req, res } = mockReqRes([report]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.canary.success'); + }); + + it('strips email and uid query params from logged URLs', () => { + const { route } = build(); + const report = violationReport({ + documentURL: + 'https://accounts.firefox.com/signin?email=user@example.com&uid=deadbeef&foo=bar', + blockedURL: + 'https://accounts.firefox.com/scripts/app.js?uid=deadbeef', + }); + const { req, res } = mockReqRes([report]); + + route.process(req, res); + + const logged = mockLogger.info.mock.calls.find( + (c) => c[0] === 'server.waict.violation' + )[1]; + expect(logged.documentURL).not.toContain('email='); + expect(logged.documentURL).not.toContain('uid='); + expect(logged.documentURL).not.toContain('user@example.com'); + // Non-PII params are preserved. + expect(logged.documentURL).toContain('foo=bar'); + expect(logged.blocked).not.toContain('uid='); + }); + + it('normalizes a single (non-array) report object', () => { + const { route, statsd } = build(); + const { req, res } = mockReqRes(violationReport()); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'missing_from_manifest', + }); + }); + + it('processes every report in a batch', () => { + const { route, statsd } = build(); + const { req, res } = mockReqRes([ + violationReport({ reason: 'missing_from_manifest' }), + violationReport({ reason: 'no_manifest_match' }), + ]); + + route.process(req, res); + + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'missing_from_manifest', + }); + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'no_manifest_match', + }); + }); + + it('skips null / non-object report entries without throwing', () => { + const { route, statsd } = build(); + const { req, res } = mockReqRes([null, 'garbage', violationReport()]); + + expect(() => route.process(req, res)).not.toThrow(); + // Only the one valid report produced a violation counter. + expect( + statsd.increment.mock.calls.filter((c) => c[0] === 'waict.violation') + .length + ).toBe(1); + }); + + it('handles a report with no URL fields without throwing', () => { + const { route, statsd } = build(); + // A malformed/minimal report: no blockedURL, no documentURL. + const { req, res } = mockReqRes([{ type: 'waict-violation', body: {} }]); + + expect(() => route.process(req, res)).not.toThrow(); + // With no blockedURL it is not a canary, so it counts as a violation. + expect(statsd.increment).toHaveBeenCalledWith('waict.violation', 1, { + reason: 'unknown', + }); + const logged = mockLogger.info.mock.calls.find( + (c) => c[0] === 'server.waict.violation' + )[1]; + expect(logged.documentURL).toBe(''); + }); + + it('accepts snake_case field aliases (blocked_url)', () => { + const { route, statsd } = build(); + const { req, res } = mockReqRes([ + { + type: 'waict-violation', + body: { + blocked_url: `https://accounts.firefox.com${CANARY_PATH}`, + reason: 'missing_from_manifest', + }, + }, + ]); + + route.process(req, res); + + // blocked_url pointing at the canary path is still recognized as canary. + expect(statsd.increment).toHaveBeenCalledWith('waict.canary.success'); + }); + + it('does not throw when statsd is not configured', () => { + const { route } = build({ statsd: undefined }); + const { req, res } = mockReqRes([violationReport()]); + expect(() => route.process(req, res)).not.toThrow(); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it('does not throw if processing a report throws (response already sent)', () => { + const { route } = build(); + const { req, res } = mockReqRes([violationReport()]); + // Make logger.info throw once to simulate a mid-loop failure after the + // response was already sent; the guard must swallow it. + mockLogger.info.mockImplementationOnce(() => { + throw new Error('boom'); + }); + + expect(() => route.process(req, res)).not.toThrow(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'server.waict.report.error', + expect.any(Object) + ); + }); +}); + +describe('post-waict-report BODY_SCHEMA validation', () => { + // Mirror the celebrate options used by the routing layer (stripUnknown for + // objects, not arrays) so this exercises real request-validation behavior. + const OPTS = { stripUnknown: { arrays: false, objects: true } }; + + function validate(body) { + return postWaictReport.BODY_SCHEMA.validate(body, OPTS); + } + + it('accepts an array of well-formed reports', () => { + const { error } = validate([violationReport(), violationReport()]); + expect(error).toBeUndefined(); + }); + + it('accepts a single (non-array) report object', () => { + const { error } = validate(violationReport()); + expect(error).toBeUndefined(); + }); + + it('rejects an array larger than the per-request cap', () => { + const tooMany = Array.from( + { length: postWaictReport.MAX_REPORTS_PER_REQUEST + 1 }, + () => violationReport() + ); + const { error } = validate(tooMany); + expect(error).toBeDefined(); + }); + + it('strips unknown keys from a report body', () => { + const report = violationReport(); + report.body.evil = 'x'.repeat(50); + report.attacker = 'y'.repeat(50); + const { value, error } = validate([report]); + + expect(error).toBeUndefined(); + expect(value[0].body.evil).toBeUndefined(); + expect(value[0].attacker).toBeUndefined(); + // Declared fields survive. + expect(value[0].body.reason).toBe('missing_from_manifest'); + }); + + it('rejects an over-long string field', () => { + const report = violationReport({ reason: 'x'.repeat(2000) }); + const { error } = validate([report]); + expect(error).toBeDefined(); + }); +}); diff --git a/packages/fxa-content-server/server/lib/static-paths.js b/packages/fxa-content-server/server/lib/static-paths.js new file mode 100644 index 00000000000..a1bb90a69e1 --- /dev/null +++ b/packages/fxa-content-server/server/lib/static-paths.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Single source of truth for resolving the on-disk static asset directories. +// Previously the `path.join(__dirname, '..', ...)` traversal was duplicated +// (with different `..` depths) across fxa-content-server.js, beta-settings.js, +// and the WAICT manifest route - fragile, since moving any of those files or +// changing the dist layout silently broke resolution. + +'use strict'; +const path = require('path'); + +// This file lives in server/lib; two levels up is the package root, which is +// what `static_directory` (default `dist`) is configured relative to. +const PACKAGE_ROOT = path.join(__dirname, '..', '..'); + +/** + * Absolute path to the directory static files are served from. + * + * @param {Object} config convict config + * @returns {String} + */ +function staticDirectory(config) { + return path.join(PACKAGE_ROOT, config.get('static_directory')); +} + +/** + * Absolute path to the served fxa-settings build under the static directory. + * + * @param {Object} config convict config + * @returns {String} + */ +function settingsStaticDirectory(config) { + return path.join( + staticDirectory(config), + 'settings', + config.get('static_settings_directory') + ); +} + +module.exports = { + staticDirectory, + settingsStaticDirectory, +}; diff --git a/packages/fxa-content-server/server/lib/url-scrubber.js b/packages/fxa-content-server/server/lib/url-scrubber.js new file mode 100644 index 00000000000..865bc5723a3 --- /dev/null +++ b/packages/fxa-content-server/server/lib/url-scrubber.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Shared helper for scrubbing PII out of URLs before they are written to logs. +// Used by the violation-report collectors (post-csp.js, post-waict-report.js), +// which log URLs that arrive in untrusted, unauthenticated report bodies. This +// is the single canonical implementation - previously the logic was +// copy-pasted into each collector and had already drifted between them. + +'use strict'; + +// The WHATWG URL parser (unlike the legacy `url.parse`) throws deterministically +// on non-URL input, which makes the try/catch below meaningful. +const { URL } = require('url'); + +// Query parameters known to carry PII in FxA URLs. Removed from any logged URL. +const PII_QUERY_PARAMS = ['email', 'uid']; + +/** + * Remove PII from a URL so it is safe to log. + * + * Strips the {@link PII_QUERY_PARAMS} query parameters and drops the fragment + * entirely (fragments can carry tokens/identifiers and are never needed for + * violation triage). Non-URL inputs (e.g. CSP keywords like `inline`/`eval`, + * or relative paths) are returned unchanged since there is nothing parseable + * to scrub. + * + * @param {String} urlToScrub + * @returns {String} the scrubbed URL, or '' for empty/non-string input + */ +function stripPIIFromUrl(urlToScrub) { + if (!urlToScrub || typeof urlToScrub !== 'string') { + return ''; + } + + let parsed; + try { + parsed = new URL(urlToScrub); + } catch (e) { + // Not an absolute URL - nothing to scrub, return as-is. + return urlToScrub; + } + + PII_QUERY_PARAMS.forEach((param) => parsed.searchParams.delete(param)); + parsed.hash = ''; + + return parsed.toString(); +} + +module.exports = { + stripPIIFromUrl, + PII_QUERY_PARAMS, +}; diff --git a/packages/fxa-content-server/server/lib/url-scrubber.test.js b/packages/fxa-content-server/server/lib/url-scrubber.test.js new file mode 100644 index 00000000000..c287a1f672e --- /dev/null +++ b/packages/fxa-content-server/server/lib/url-scrubber.test.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const { stripPIIFromUrl } = require('./url-scrubber'); + +describe('stripPIIFromUrl', () => { + it('returns "" for empty / non-string input', () => { + expect(stripPIIFromUrl('')).toBe(''); + expect(stripPIIFromUrl(undefined)).toBe(''); + expect(stripPIIFromUrl(null)).toBe(''); + expect(stripPIIFromUrl(42)).toBe(''); + expect(stripPIIFromUrl({})).toBe(''); + }); + + it('removes email and uid query params, preserving others', () => { + const scrubbed = stripPIIFromUrl( + 'https://accounts.firefox.com/signin?email=user@example.com&uid=deadbeef&foo=bar' + ); + expect(scrubbed).not.toContain('email='); + expect(scrubbed).not.toContain('uid='); + expect(scrubbed).not.toContain('user@example.com'); + expect(scrubbed).toContain('foo=bar'); + }); + + it('drops the fragment (can carry tokens)', () => { + const scrubbed = stripPIIFromUrl( + 'https://accounts.firefox.com/reset#token=secret' + ); + expect(scrubbed).not.toContain('token=secret'); + expect(scrubbed).not.toContain('#'); + }); + + it('leaves a clean URL essentially unchanged', () => { + const scrubbed = stripPIIFromUrl( + 'https://accounts.firefox.com/settings/app.js' + ); + expect(scrubbed).toBe('https://accounts.firefox.com/settings/app.js'); + }); + + it('returns non-URL input unchanged (e.g. CSP keywords)', () => { + // The URL constructor throws on these; there is nothing to scrub. + expect(stripPIIFromUrl('inline')).toBe('inline'); + expect(stripPIIFromUrl('eval')).toBe('eval'); + expect(stripPIIFromUrl('/relative/path?email=x')).toBe( + '/relative/path?email=x' + ); + }); + + it('strips PII case-sensitively per the known param names', () => { + // Only the exact lower-case param names are stripped; documents the contract. + const scrubbed = stripPIIFromUrl( + 'https://accounts.firefox.com/x?uid=abc&other=keep' + ); + expect(scrubbed).not.toContain('uid='); + expect(scrubbed).toContain('other=keep'); + }); +}); diff --git a/packages/fxa-content-server/server/lib/waict-manifest-builder.js b/packages/fxa-content-server/server/lib/waict-manifest-builder.js new file mode 100644 index 00000000000..3dc98213fec --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict-manifest-builder.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Pure, grunt-agnostic core of the WAICT manifest generation. The grunt task +// (grunttasks/waict-manifest.js) is a thin adapter that supplies the file list, +// a byte reader, and a warn() sink; all decision logic lives here so it can be +// unit-tested without a grunt/filesystem harness. +// +// See https://github.com/waict-wg/waict-integrity-spec. + +'use strict'; +const crypto = require('crypto'); + +// Frontend unit-test bundles are only emitted in development and are not part +// of any shipped page. +const TEST_BUNDLE = /\/(test|testDependencies)\.bundle(\.|\b)/; + +/** + * Pick which fxa-settings env directory under dist/settings is actually served. + * A given build usually emits a single env (e.g. dist/settings/dev), so when + * exactly one is present we use it; otherwise we fall back to `fallback`. + * + * @param {String[]} envDirs directory names found under dist/settings + * @param {String} [envOverride] value of STATIC_SETTINGS_DIRECTORY, if set + * @param {String} [fallback] used when the directory can't be uniquely resolved + * @returns {String} + */ +function pickSettingsDirectory(envDirs, envOverride, fallback = 'prod') { + if (envOverride) { + return envOverride; + } + return envDirs.length === 1 ? envDirs[0] : fallback; +} + +/** + * Map a dist-relative path to the (same-origin) URL the browser requests it + * from, or return null if the file is not served. + * + * @param {String} distRelative forward-slash path relative to dist + * @param {String} settingsDirectory the served fxa-settings env directory + * @returns {String|null} + */ +function toServedUrl(distRelative, settingsDirectory) { + if (distRelative.indexOf('settings/') === 0) { + // settings// -> /settings/, but only for the served env. + const withoutPrefix = distRelative.slice('settings/'.length); + const slash = withoutPrefix.indexOf('/'); + if (slash === -1) { + return null; + } + const env = withoutPrefix.slice(0, slash); + if (env !== settingsDirectory) { + return null; + } + return '/settings/' + withoutPrefix.slice(slash + 1); + } + + return '/' + distRelative; +} + +/** + * SHA-256 of the given bytes, base64-encoded. WAICT v1 always uses SHA-256 + * (matching SRI's `sha256-` convention but without the prefix). + * + * @param {Buffer|String} bytes + * @returns {String} + */ +function sha256Base64(bytes) { + return crypto.createHash('sha256').update(bytes).digest('base64'); +} + +/** + * Absolute (or same-origin) URL a settings script is fetched from. In stage/prod + * the settings build bakes a CDN `baseUrl` (PUBLIC_URL) into index.html, so the + * browser fetches `/`; in dev the base is empty and content-server + * serves it same-origin at `/settings/`. + * + * @param {String} rest path after `settings//` + * @param {String} baseUrl recorded settings origin, or '' for same-origin + * @returns {String} + */ +function settingsServedUrl(rest, baseUrl) { + if (baseUrl) { + return baseUrl.replace(/\/+$/, '') + '/' + rest; + } + return '/settings/' + rest; +} + +/** + * Build the WAICT manifest from an abstract file list. No filesystem or grunt + * dependency: `readBytes(distRelative)` returns the file's bytes and `warn(msg)` + * surfaces recoverable problems (unknown asset mode, missing version). + * + * Pinning strategy - prefer per-URL `hashes` (strong), fall back to url-agnostic + * `any_hashes` (content-addressed) only where a script's served URL cannot be + * known at build time: + * - fxa-settings scripts: URL is knowable (same-origin `/settings/...`, or the + * recorded CDN `settingsBaseUrl` for stage/prod) -> URL-pinned in `hashes`. + * - content-server's own scripts: referenced via the runtime-interpolated + * `{{{ staticResourceUrl }}}`, so the absolute URL is NOT knowable at build + * time -> content-addressed in `any_hashes` (the unavoidable case). + * - cache-busted settings assets declared `any`: the `?v=` is volatile, so + * they are intentionally content-addressed too. + * + * @param {Object} args + * @param {String[]} args.files dist-relative forward-slash paths + * @param {(distRelative: string) => (Buffer|string)} args.readBytes + * @param {String} args.settingsDirectory served fxa-settings env directory + * @param {String} [args.settingsBaseUrl] CDN origin settings is served from ('' = same-origin) + * @param {Object} [args.publicAssets] declared cache-busted public/ assets + * @param {(msg: string) => void} [args.warn] + * @returns {{ manifest: {hashes: Object, any_hashes: string[]}, count: number }} + */ +function buildManifest({ + files, + readBytes, + settingsDirectory, + settingsBaseUrl = '', + publicAssets = {}, + warn = () => {}, +}) { + const hashes = {}; + // De-duplicate: several URLs can share content, and content-addressing keys + // by hash alone. + const anyHashes = new Set(); + let count = 0; + + files.forEach((distRelative) => { + if (TEST_BUNDLE.test('/' + distRelative)) { + return; + } + + const servedUrl = toServedUrl(distRelative, settingsDirectory); + if (!servedUrl) { + return; + } + + const hash = sha256Base64(readBytes(distRelative)); + count++; + + // Content-server's own scripts (everything not under /settings/) are + // referenced via the runtime-interpolated staticResourceUrl, so we can't + // pin their URL at build time - content-address them. + if (!servedUrl.startsWith('/settings/')) { + anyHashes.add(hash); + return; + } + + const rest = servedUrl.slice('/settings/'.length); + const url = settingsServedUrl(rest, settingsBaseUrl); + const declared = publicAssets[rest]; + + if (declared) { + // Route declared cache-busted public/ scripts. `any` matches the content + // hash regardless of URL (absorbs the ?v=); `exact` pins the precise ?v= + // URL the build references it with. + if (declared.mode === 'any') { + anyHashes.add(hash); + } else if (declared.mode === 'exact' && declared.v) { + hashes[url + '?v=' + declared.v] = hash; + } else if (declared.mode === 'exact') { + // exact mode with no version can't produce a matchable ?v= key; fall + // back to url-agnostic matching rather than emitting `?v=undefined`. + warn( + `waict-manifest: "${rest}" is exact mode but has no version; ` + + 'falling back to url-agnostic (any) matching' + ); + anyHashes.add(hash); + } else { + // Unknown mode: don't silently drop the file from the manifest. + warn( + `waict-manifest: unknown mode "${declared.mode}" for "${rest}"; ` + + 'keying by served URL' + ); + hashes[url] = hash; + } + return; + } + + hashes[url] = hash; + }); + + return { + manifest: { hashes, any_hashes: Array.from(anyHashes) }, + count, + }; +} + +module.exports = { + TEST_BUNDLE, + pickSettingsDirectory, + toServedUrl, + sha256Base64, + settingsServedUrl, + buildManifest, +}; diff --git a/packages/fxa-content-server/server/lib/waict-manifest-builder.test.js b/packages/fxa-content-server/server/lib/waict-manifest-builder.test.js new file mode 100644 index 00000000000..23e378f4690 --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict-manifest-builder.test.js @@ -0,0 +1,240 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const crypto = require('crypto'); +const { + pickSettingsDirectory, + toServedUrl, + sha256Base64, + settingsServedUrl, + buildManifest, +} = require('./waict-manifest-builder'); + +describe('pickSettingsDirectory', () => { + it('honors the env override above everything else', () => { + expect(pickSettingsDirectory(['dev', 'prod'], 'stage')).toBe('stage'); + expect(pickSettingsDirectory([], 'stage')).toBe('stage'); + }); + + it('uses the single built directory when exactly one exists', () => { + expect(pickSettingsDirectory(['dev'])).toBe('dev'); + }); + + it('falls back to prod when zero or multiple directories exist', () => { + expect(pickSettingsDirectory([])).toBe('prod'); + expect(pickSettingsDirectory(['dev', 'stage'])).toBe('prod'); + }); + + it('accepts a custom fallback', () => { + expect(pickSettingsDirectory([], undefined, 'dev')).toBe('dev'); + }); +}); + +describe('toServedUrl', () => { + it('rewrites settings//x to /settings/x', () => { + expect(toServedUrl('settings/prod/static/js/main.js', 'prod')).toBe( + '/settings/static/js/main.js' + ); + }); + + it('drops settings files for a non-served env', () => { + expect(toServedUrl('settings/dev/static/js/main.js', 'prod')).toBeNull(); + }); + + it('drops a bare settings/ path with no trailing file', () => { + expect(toServedUrl('settings/prod', 'prod')).toBeNull(); + }); + + it('serves non-settings paths at the root', () => { + expect(toServedUrl('bundle/app.bundle.js', 'prod')).toBe( + '/bundle/app.bundle.js' + ); + }); +}); + +describe('sha256Base64', () => { + it('matches a known SHA-256 base64 digest', () => { + const bytes = Buffer.from('hello'); + const expected = crypto + .createHash('sha256') + .update(bytes) + .digest('base64'); + expect(sha256Base64(bytes)).toBe(expected); + }); +}); + +describe('settingsServedUrl', () => { + it('uses a same-origin /settings/ path when no base is given', () => { + expect(settingsServedUrl('static/js/main.js', '')).toBe( + '/settings/static/js/main.js' + ); + }); + + it('prefixes the CDN base (trimming a trailing slash) when given', () => { + expect( + settingsServedUrl( + 'static/js/main.js', + 'https://cdn.accounts.firefox.com/settings/prod' + ) + ).toBe('https://cdn.accounts.firefox.com/settings/prod/static/js/main.js'); + expect(settingsServedUrl('x.js', 'https://cdn/settings/prod/')).toBe( + 'https://cdn/settings/prod/x.js' + ); + }); +}); + +describe('buildManifest', () => { + const readBytes = (rel) => Buffer.from('bytes-of:' + rel); + const hashOf = (rel) => sha256Base64(readBytes(rel)); + + it('URL-pins settings scripts and content-addresses own scripts', () => { + const { manifest, count } = buildManifest({ + files: ['bundle/app.bundle.js', 'settings/prod/static/js/main.js'], + readBytes, + settingsDirectory: 'prod', + }); + + expect(count).toBe(2); + // Content-server's own script -> any_hashes (runtime staticResourceUrl). + expect(manifest.any_hashes).toEqual([hashOf('bundle/app.bundle.js')]); + expect(manifest.hashes['/bundle/app.bundle.js']).toBeUndefined(); + // Settings script -> URL-pinned. + expect(manifest.hashes['/settings/static/js/main.js']).toBe( + hashOf('settings/prod/static/js/main.js') + ); + }); + + it('pins settings scripts by absolute CDN URL when a base is recorded', () => { + const { manifest } = buildManifest({ + files: ['settings/prod/static/js/main.js'], + readBytes, + settingsDirectory: 'prod', + settingsBaseUrl: 'https://cdn.accounts.firefox.com/settings/prod', + }); + + expect( + manifest.hashes[ + 'https://cdn.accounts.firefox.com/settings/prod/static/js/main.js' + ] + ).toBe(hashOf('settings/prod/static/js/main.js')); + // No stale relative key. + expect(manifest.hashes['/settings/static/js/main.js']).toBeUndefined(); + }); + + it('excludes test/testDependencies bundles', () => { + const { manifest, count } = buildManifest({ + files: [ + 'bundle/test.bundle.js', + 'bundle/testDependencies.bundle.js', + 'settings/prod/static/js/main.js', + ], + readBytes, + settingsDirectory: 'prod', + }); + + expect(count).toBe(1); + expect(Object.keys(manifest.hashes)).toEqual([ + '/settings/static/js/main.js', + ]); + }); + + it('skips settings files for a non-served env', () => { + const { manifest, count } = buildManifest({ + files: ['settings/dev/static/js/main.js'], + readBytes, + settingsDirectory: 'prod', + }); + + expect(count).toBe(0); + expect(manifest.hashes).toEqual({}); + expect(manifest.any_hashes).toEqual([]); + }); + + it('de-duplicates identical content in any_hashes', () => { + const sameBytes = () => Buffer.from('identical'); + const { manifest } = buildManifest({ + files: ['bundle/a.js', 'bundle/b.js'], + readBytes: sameBytes, + settingsDirectory: 'prod', + }); + + expect(manifest.any_hashes).toHaveLength(1); + }); + + it('routes a declared "any" asset into any_hashes', () => { + const { manifest } = buildManifest({ + files: ['settings/prod/query-fix.js'], + readBytes, + settingsDirectory: 'prod', + publicAssets: { 'query-fix.js': { mode: 'any' } }, + }); + + expect(manifest.any_hashes).toEqual([hashOf('settings/prod/query-fix.js')]); + expect(manifest.hashes).toEqual({}); + }); + + it('routes a declared "exact" asset to a ?v= key', () => { + const { manifest } = buildManifest({ + files: ['settings/prod/lang-fix.js'], + readBytes, + settingsDirectory: 'prod', + publicAssets: { 'lang-fix.js': { mode: 'exact', v: 'abc123' } }, + }); + + expect(manifest.hashes['/settings/lang-fix.js?v=abc123']).toBe( + hashOf('settings/prod/lang-fix.js') + ); + }); + + it('pins an "exact" asset to the CDN base + ?v= when a base is recorded', () => { + const { manifest } = buildManifest({ + files: ['settings/prod/lang-fix.js'], + readBytes, + settingsDirectory: 'prod', + settingsBaseUrl: 'https://cdn/settings/prod', + publicAssets: { 'lang-fix.js': { mode: 'exact', v: 'abc123' } }, + }); + + expect(manifest.hashes['https://cdn/settings/prod/lang-fix.js?v=abc123']).toBe( + hashOf('settings/prod/lang-fix.js') + ); + }); + + it('falls back to any-hash + warns when exact mode has no version', () => { + const warn = jest.fn(); + const { manifest } = buildManifest({ + files: ['settings/prod/lang-fix.js'], + readBytes, + settingsDirectory: 'prod', + publicAssets: { 'lang-fix.js': { mode: 'exact' } }, + warn, + }); + + // No ?v=undefined key is ever emitted. + expect( + Object.keys(manifest.hashes).some((k) => k.includes('undefined')) + ).toBe(false); + expect(manifest.any_hashes).toEqual([hashOf('settings/prod/lang-fix.js')]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no version')); + }); + + it('keys by served URL + warns for an unknown mode (never silently dropped)', () => { + const warn = jest.fn(); + const { manifest, count } = buildManifest({ + files: ['settings/prod/weird.js'], + readBytes, + settingsDirectory: 'prod', + publicAssets: { 'weird.js': { mode: 'bogus' } }, + warn, + }); + + expect(count).toBe(1); + expect(manifest.hashes['/settings/weird.js']).toBe( + hashOf('settings/prod/weird.js') + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown mode')); + }); +}); diff --git a/packages/fxa-content-server/server/lib/waict.test.js b/packages/fxa-content-server/server/lib/waict.test.js new file mode 100644 index 00000000000..c569df77c79 --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict.test.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const waict = require('./waict'); +const { buildHeaderValue, REPORT_ENDPOINT_NAME } = waict; + +function baseConfig(overrides = {}) { + return { + manifestPath: '/waict-manifest.json', + maxAge: 0, + blockedDestinations: ['script'], + reportUri: '/_/waict-violation', + ...overrides, + }; +} + +describe('waict buildHeaderValue', () => { + it('emits the structured-field parameters in report mode', () => { + const value = buildHeaderValue(baseConfig()); + + // Order matters for readability but the spec parses by name; assert each + // parameter is present and well-formed. + expect(value).toContain('max-age=0'); + expect(value).toContain('mode=report'); + expect(value).toContain('blocked-destinations=(script)'); + expect(value).toContain(`endpoints=(${REPORT_ENDPOINT_NAME})`); + // manifest is an sf-string and must be double-quoted. + expect(value).toContain('manifest="/waict-manifest.json"'); + }); + + it('is always non-blocking (mode=report), never enforcing', () => { + const value = buildHeaderValue(baseConfig({ maxAge: 86400 })); + expect(value).toContain('mode=report'); + expect(value).not.toContain('mode=enforce'); + }); + + it('joins multiple blocked destinations as a space-separated inner list', () => { + const value = buildHeaderValue( + baseConfig({ blockedDestinations: ['script', 'style'] }) + ); + expect(value).toContain('blocked-destinations=(script style)'); + }); + + it('reflects the configured max-age', () => { + const value = buildHeaderValue(baseConfig({ maxAge: 3600 })); + expect(value).toContain('max-age=3600'); + }); + + it('quotes the configured manifest path', () => { + const value = buildHeaderValue( + baseConfig({ manifestPath: '/custom/manifest.json' }) + ); + expect(value).toContain('manifest="/custom/manifest.json"'); + }); +}); + +describe('waict middleware', () => { + // Build a minimal response object compatible with the `on-headers` module + // used by html-middleware: it wraps res.writeHead and fires its listener + // synchronously when writeHead is called. We drive that by setting the + // content-type header, then calling writeHead to simulate response start. + function mockRes() { + const headers = {}; + return { + setHeader: jest.fn((name, val) => { + headers[name.toLowerCase()] = val; + }), + getHeader: jest.fn((name) => headers[name.toLowerCase()]), + removeHeader: jest.fn((name) => { + delete headers[name.toLowerCase()]; + }), + writeHead: jest.fn(), + }; + } + + it('calls next immediately without waiting for the response', () => { + const mw = waict(baseConfig({ statsd: { increment: jest.fn() } })); + const next = jest.fn(); + + mw({}, mockRes(), next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('sets the WAICT and Reporting-Endpoints headers on HTML responses', () => { + const statsd = { increment: jest.fn() }; + const mw = waict(baseConfig({ statsd })); + const res = mockRes(); + + mw({}, res, jest.fn()); + + // Simulate an HTML document response. + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.writeHead(200); + + expect(res.setHeader).toHaveBeenCalledWith( + 'Integrity-Policy-WAICT-v1', + expect.stringContaining('mode=report') + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Reporting-Endpoints', + `${REPORT_ENDPOINT_NAME}="/_/waict-violation"` + ); + expect(statsd.increment).toHaveBeenCalledWith('waict.document_served'); + }); + + it('does not set WAICT headers on non-HTML responses', () => { + const statsd = { increment: jest.fn() }; + const mw = waict(baseConfig({ statsd })); + const res = mockRes(); + + mw({}, res, jest.fn()); + + // A JSON/API response should be left untouched. + res.setHeader('content-type', 'application/json'); + res.writeHead(200); + + expect(res.setHeader).not.toHaveBeenCalledWith( + 'Integrity-Policy-WAICT-v1', + expect.anything() + ); + expect(statsd.increment).not.toHaveBeenCalled(); + }); + + it('does not throw when statsd is not configured', () => { + const mw = waict(baseConfig()); + const res = mockRes(); + + mw({}, res, jest.fn()); + + res.setHeader('content-type', 'text/html'); + expect(() => res.writeHead(200)).not.toThrow(); + expect(res.setHeader).toHaveBeenCalledWith( + 'Integrity-Policy-WAICT-v1', + expect.any(String) + ); + }); +}); diff --git a/packages/fxa-settings/scripts/build.js b/packages/fxa-settings/scripts/build.js index d1948dee907..017956ffd61 100644 --- a/packages/fxa-settings/scripts/build.js +++ b/packages/fxa-settings/scripts/build.js @@ -154,10 +154,17 @@ checkBrowsers(paths.appPath, isInteractive) // Written after the build (emptyDirSync above would wipe it earlier) so // copy:settings carries it into the content-server dist for WAICT - // manifest generation. + // manifest generation. `baseUrl` records the origin these scripts are + // actually served from (the CDN PUBLIC_URL for stage/prod, '' for + // same-origin dev) so the manifest task can pin them by absolute URL. + // Consumed by ../fxa-content-server/server/lib/waict-manifest-builder.js. + const waictSidecar = { + baseUrl: process.env.PUBLIC_URL || '', + assets: WAICT_PUBLIC_ASSETS, + }; fs.writeFileSync( path.join(paths.appBuild, 'waict-public-assets.json'), - JSON.stringify(WAICT_PUBLIC_ASSETS, null, 2) + JSON.stringify(waictSidecar, null, 2) ); const appPackage = require(paths.appPackageJson); From 647edbd88a6de8a84549e108583ee6822eed4e60 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Tue, 7 Jul 2026 19:29:43 +0200 Subject: [PATCH 5/7] Clean-up --- .../fxa-content-server/grunttasks/build.js | 6 ++-- .../grunttasks/waict-manifest.js | 14 ++------ .../server/lib/waict-manifest-builder.js | 36 ++++++++----------- 3 files changed, 19 insertions(+), 37 deletions(-) diff --git a/packages/fxa-content-server/grunttasks/build.js b/packages/fxa-content-server/grunttasks/build.js index c22d85a4fd4..1a5e3414e5c 100644 --- a/packages/fxa-content-server/grunttasks/build.js +++ b/packages/fxa-content-server/grunttasks/build.js @@ -89,8 +89,8 @@ module.exports = function (grunt) { // run it through webpack again. 'copy:settings', - // generate the WAICT integrity manifest. Must be last so it hashes both - // the content-server bundles and the copied fxa-settings bundles. - 'waict-manifest', + // task to generate the WAICT integrity manifest. Must be last so it hashes + // both the content-server bundles and the copied fxa-settings bundles. + 'generate-waict-manifest', ]); }; diff --git a/packages/fxa-content-server/grunttasks/waict-manifest.js b/packages/fxa-content-server/grunttasks/waict-manifest.js index 445c289e068..5db14b237f0 100644 --- a/packages/fxa-content-server/grunttasks/waict-manifest.js +++ b/packages/fxa-content-server/grunttasks/waict-manifest.js @@ -2,17 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// Generate the WAICT integrity manifest from the built artifacts. The manifest -// maps every first-party script's served URL to the SHA-256 hash of its bytes. -// -// This must run at the very end of the build (after `copy:settings`) so that -// `dist` contains both the content-server bundles and the copied fxa-settings -// bundles - WAICT report mode covers the whole origin's scripts. -// -// This file is a thin grunt/filesystem adapter; the decision logic lives in -// ../server/lib/waict-manifest-builder.js (unit-tested there). -// -// See https://github.com/waict-wg/waict-integrity-spec. +// Generate the WAICT integrity manifest from the built artifacts. 'use strict'; const fs = require('fs'); @@ -64,7 +54,7 @@ module.exports = function (grunt) { } grunt.registerTask( - 'waict-manifest', + 'generate-waict-manifest', 'Generate the WAICT integrity manifest of served script hashes', function () { const dist = grunt.config.get('yeoman.dist'); diff --git a/packages/fxa-content-server/server/lib/waict-manifest-builder.js b/packages/fxa-content-server/server/lib/waict-manifest-builder.js index 3dc98213fec..2e48eea3451 100644 --- a/packages/fxa-content-server/server/lib/waict-manifest-builder.js +++ b/packages/fxa-content-server/server/lib/waict-manifest-builder.js @@ -2,24 +2,19 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// Pure, grunt-agnostic core of the WAICT manifest generation. The grunt task -// (grunttasks/waict-manifest.js) is a thin adapter that supplies the file list, -// a byte reader, and a warn() sink; all decision logic lives here so it can be -// unit-tested without a grunt/filesystem harness. -// -// See https://github.com/waict-wg/waict-integrity-spec. - 'use strict'; const crypto = require('crypto'); -// Frontend unit-test bundles are only emitted in development and are not part -// of any shipped page. +// Matches the `test.bundle.js` / `testDependencies.bundle.js` webpack outputs. +// These come from the `test` (../tests/webpack.js browser test suite) and +// `testDependencies` (jquery/chai/mocha/sinon) entries that webpack.config.js +// adds only when ENV === 'development'. A production build never emits them, so +// this filter only matters for dev builds - it keeps the in-browser test +// scaffolding out of the manifest since it is not part of any shipped page. const TEST_BUNDLE = /\/(test|testDependencies)\.bundle(\.|\b)/; /** * Pick which fxa-settings env directory under dist/settings is actually served. - * A given build usually emits a single env (e.g. dist/settings/dev), so when - * exactly one is present we use it; otherwise we fall back to `fallback`. * * @param {String[]} envDirs directory names found under dist/settings * @param {String} [envOverride] value of STATIC_SETTINGS_DIRECTORY, if set @@ -60,9 +55,6 @@ function toServedUrl(distRelative, settingsDirectory) { } /** - * SHA-256 of the given bytes, base64-encoded. WAICT v1 always uses SHA-256 - * (matching SRI's `sha256-` convention but without the prefix). - * * @param {Buffer|String} bytes * @returns {String} */ @@ -88,11 +80,9 @@ function settingsServedUrl(rest, baseUrl) { } /** - * Build the WAICT manifest from an abstract file list. No filesystem or grunt - * dependency: `readBytes(distRelative)` returns the file's bytes and `warn(msg)` - * surfaces recoverable problems (unknown asset mode, missing version). + * Build the WAICT manifest from an abstract file list. * - * Pinning strategy - prefer per-URL `hashes` (strong), fall back to url-agnostic + * Prefer per-URL `hashes` (strong), fall back to url-agnostic * `any_hashes` (content-addressed) only where a script's served URL cannot be * known at build time: * - fxa-settings scripts: URL is knowable (same-origin `/settings/...`, or the @@ -121,8 +111,7 @@ function buildManifest({ warn = () => {}, }) { const hashes = {}; - // De-duplicate: several URLs can share content, and content-addressing keys - // by hash alone. + // Deduplicate potential shared content const anyHashes = new Set(); let count = 0; @@ -141,7 +130,7 @@ function buildManifest({ // Content-server's own scripts (everything not under /settings/) are // referenced via the runtime-interpolated staticResourceUrl, so we can't - // pin their URL at build time - content-address them. + // pin their URL at build time. if (!servedUrl.startsWith('/settings/')) { anyHashes.add(hash); return; @@ -158,6 +147,9 @@ function buildManifest({ if (declared.mode === 'any') { anyHashes.add(hash); } else if (declared.mode === 'exact' && declared.v) { + // lang-fix.js goes into `hashes` (not `any_hashes`) to demonstrate that + // a cache-busted `?v=` script can still be URL-pinned rather than only + // content-addressed. hashes[url + '?v=' + declared.v] = hash; } else if (declared.mode === 'exact') { // exact mode with no version can't produce a matchable ?v= key; fall @@ -168,7 +160,7 @@ function buildManifest({ ); anyHashes.add(hash); } else { - // Unknown mode: don't silently drop the file from the manifest. + // Unknown mode: by default the file goes to the URL-keyed hashes list. warn( `waict-manifest: unknown mode "${declared.mode}" for "${rest}"; ` + 'keying by served URL' From 17c26b67242058acafa67b04ddfa718450147e12 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Tue, 7 Jul 2026 19:37:44 +0200 Subject: [PATCH 6/7] Clean-up --- .../server/lib/routes/get-waict-canary.js | 4 +--- packages/fxa-content-server/server/lib/waict.js | 6 ------ packages/fxa-settings/scripts/build.js | 5 +---- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/fxa-content-server/server/lib/routes/get-waict-canary.js b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js index fe2ebd6643e..9511541a5cf 100644 --- a/packages/fxa-content-server/server/lib/routes/get-waict-canary.js +++ b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js @@ -5,9 +5,7 @@ /** * Serve the WAICT canary script. The canary is deliberately absent from the * integrity manifest, so referencing it from a page produces a guaranteed - * `missing_from_manifest` violation report on every load. A steady stream of - * canary reports proves the browser -> report-endpoint pipeline is live; - * their disappearance means reporting is broken, not that the origin is clean. + * `missing_from_manifest` violation report on every load. */ 'use strict'; diff --git a/packages/fxa-content-server/server/lib/waict.js b/packages/fxa-content-server/server/lib/waict.js index 65503583caa..791869f2251 100644 --- a/packages/fxa-content-server/server/lib/waict.js +++ b/packages/fxa-content-server/server/lib/waict.js @@ -4,12 +4,6 @@ // Middleware that emits the WAICT (Web Application Integrity, Consistency and // Transparency) `Integrity-Policy-WAICT-v1` response header in *report* mode. -// In report mode the browser only logs and reports integrity violations - it -// never blocks or alters resource loading - so it is safe to ship while we -// validate manifest coverage. Headers are only sent when `waict.enabled` is -// set (default false), and only on HTML document responses. -// -// See https://github.com/waict-wg/waict-integrity-spec and Firefox bug 2017652. 'use strict'; const htmlOnly = require('./html-middleware'); diff --git a/packages/fxa-settings/scripts/build.js b/packages/fxa-settings/scripts/build.js index 017956ffd61..88464a30ccd 100644 --- a/packages/fxa-settings/scripts/build.js +++ b/packages/fxa-settings/scripts/build.js @@ -154,10 +154,7 @@ checkBrowsers(paths.appPath, isInteractive) // Written after the build (emptyDirSync above would wipe it earlier) so // copy:settings carries it into the content-server dist for WAICT - // manifest generation. `baseUrl` records the origin these scripts are - // actually served from (the CDN PUBLIC_URL for stage/prod, '' for - // same-origin dev) so the manifest task can pin them by absolute URL. - // Consumed by ../fxa-content-server/server/lib/waict-manifest-builder.js. + // manifest generation. const waictSidecar = { baseUrl: process.env.PUBLIC_URL || '', assets: WAICT_PUBLIC_ASSETS, From 8e6010dc5f1391cf3353c996d985318a58a53ec6 Mon Sep 17 00:00:00 2001 From: Anna Weine Date: Tue, 7 Jul 2026 20:40:00 +0200 Subject: [PATCH 7/7] Changed in scrubber --- .../server/lib/url-scrubber.js | 31 +++++++++++++------ .../server/lib/url-scrubber.test.js | 14 ++++++--- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/fxa-content-server/server/lib/url-scrubber.js b/packages/fxa-content-server/server/lib/url-scrubber.js index 865bc5723a3..8ef0f35ca71 100644 --- a/packages/fxa-content-server/server/lib/url-scrubber.js +++ b/packages/fxa-content-server/server/lib/url-scrubber.js @@ -18,34 +18,45 @@ const { URL } = require('url'); const PII_QUERY_PARAMS = ['email', 'uid']; /** - * Remove PII from a URL so it is safe to log. - * - * Strips the {@link PII_QUERY_PARAMS} query parameters and drops the fragment - * entirely (fragments can carry tokens/identifiers and are never needed for - * violation triage). Non-URL inputs (e.g. CSP keywords like `inline`/`eval`, - * or relative paths) are returned unchanged since there is nothing parseable - * to scrub. + * Remove PII (email/uid query params + fragment) from a URL so it is safe to + * log. Relative URLs are scrubbed too; non-URL tokens are returned unchanged. * * @param {String} urlToScrub - * @returns {String} the scrubbed URL, or '' for empty/non-string input + * @returns {String} scrubbed URL, or '' for empty/non-string input */ function stripPIIFromUrl(urlToScrub) { if (!urlToScrub || typeof urlToScrub !== 'string') { return ''; } + // Parse absolute URLs directly; fall back to a throwaway base so relative + // URLs (which `new URL` rejects on their own) are still scrubbed. let parsed; + let isRelative = false; try { parsed = new URL(urlToScrub); } catch (e) { - // Not an absolute URL - nothing to scrub, return as-is. + try { + parsed = new URL(urlToScrub, 'https://waict.invalid'); + isRelative = true; + } catch (e2) { + // Not a URL at all (e.g. a CSP keyword like "inline"/"eval"). + return urlToScrub; + } + } + + const hasPII = + PII_QUERY_PARAMS.some((param) => parsed.searchParams.has(param)) || + parsed.hash !== ''; + if (!hasPII) { + // Nothing to strip; return the input untouched to preserve its exact shape. return urlToScrub; } PII_QUERY_PARAMS.forEach((param) => parsed.searchParams.delete(param)); parsed.hash = ''; - return parsed.toString(); + return isRelative ? parsed.pathname + parsed.search : parsed.toString(); } module.exports = { diff --git a/packages/fxa-content-server/server/lib/url-scrubber.test.js b/packages/fxa-content-server/server/lib/url-scrubber.test.js index c287a1f672e..01c41b00a24 100644 --- a/packages/fxa-content-server/server/lib/url-scrubber.test.js +++ b/packages/fxa-content-server/server/lib/url-scrubber.test.js @@ -40,13 +40,17 @@ describe('stripPIIFromUrl', () => { expect(scrubbed).toBe('https://accounts.firefox.com/settings/app.js'); }); - it('returns non-URL input unchanged (e.g. CSP keywords)', () => { - // The URL constructor throws on these; there is nothing to scrub. + it('returns non-URL tokens unchanged (e.g. CSP keywords)', () => { expect(stripPIIFromUrl('inline')).toBe('inline'); expect(stripPIIFromUrl('eval')).toBe('eval'); - expect(stripPIIFromUrl('/relative/path?email=x')).toBe( - '/relative/path?email=x' - ); + }); + + it('scrubs relative URLs too, preserving the relative shape', () => { + expect( + stripPIIFromUrl('/reset?email=user@example.com&uid=abc&keep=1') + ).toBe('/reset?keep=1'); + // No PII -> returned unchanged. + expect(stripPIIFromUrl('/relative/path')).toBe('/relative/path'); }); it('strips PII case-sensitively per the known param names', () => {