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/build.js b/packages/fxa-content-server/grunttasks/build.js index 132ad25d808..1a5e3414e5c 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', + + // 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 new file mode 100644 index 00000000000..5db14b237f0 --- /dev/null +++ b/packages/fxa-content-server/grunttasks/waict-manifest.js @@ -0,0 +1,113 @@ +/* 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. + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { + pickSettingsDirectory, + buildManifest, +} = require('../server/lib/waict-manifest-builder'); + +module.exports = function (grunt) { + /** + * 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, warning if it can't be uniquely resolved. + * + * @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. + } + + 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 pickSettingsDirectory(envDirs, undefined, 'prod'); + } + + grunt.registerTask( + 'generate-waict-manifest', + 'Generate the WAICT integrity manifest of served script hashes', + function () { + const dist = grunt.config.get('yeoman.dist'); + const settingsDirectory = resolveSettingsDirectory(dist); + + // 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', + settingsDirectory, + 'waict-public-assets.json' + ); + try { + 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 { baseUrl: '', assets: {} }; + } + })(); + + const files = grunt.file + .expand({ cwd: dist }, '**/*.js') + .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 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 (' + + 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 b7327264d43..1bc3dcf1c8e 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -70,14 +70,11 @@ 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 { 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'), @@ -148,6 +145,18 @@ 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'), + statsd, + }) + ); + } + app.disable('x-powered-by'); app.use(routeLogging()); @@ -164,11 +173,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/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 344ff9e94b6..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() { @@ -171,6 +162,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 +243,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 +294,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/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 15e3768f19f..d54853cb4a5 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -141,6 +141,44 @@ 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, + }, + 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, 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..b40f129e7ae 100644 --- a/packages/fxa-content-server/server/lib/routes.js +++ b/packages/fxa-content-server/server/lib/routes.js @@ -62,6 +62,27 @@ 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 (canaryEnabled) { + routes.push(require('./routes/get-waict-canary')(config)); + } + } + 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-canary.js b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js new file mode 100644 index 00000000000..9511541a5cf --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-canary.js @@ -0,0 +1,34 @@ +/* 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. + */ + +'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'; + +// 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: 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'); + res.type('application/javascript'); + res.send(CANARY_BODY); + }, + }; +}; + +module.exports.CANARY_PATH = CANARY_PATH; 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 new file mode 100644 index 00000000000..59a215c835b --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/get-waict-manifest.js @@ -0,0 +1,47 @@ +/* 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 { staticDirectory } = require('../static-paths'); + +const MANIFEST_CONTENT_TYPE = 'application/waict-integrity-manifest'; + +module.exports = function (config) { + const manifestFile = path.join( + staticDirectory(config), + '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/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 new file mode 100644 index 00000000000..d208170183f --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/post-waict-report.js @@ -0,0 +1,175 @@ +/* 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`. + * + * 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'); +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). +function isCanaryReport(blockedUrl, canaryPath) { + if (!blockedUrl || typeof blockedUrl !== 'string' || !canaryPath) { + return false; + } + + try { + return new URL(blockedUrl).pathname === canaryPath; + } catch (e) { + return false; + } +} + +module.exports = function (options = {}) { + const statsd = options.statsd; + const canaryPath = options.canaryPath; + + 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 }); + + // 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]; + + // 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.violation', 1, { + reason: allowedReasonTag(body.reason), + }); + } + + 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..8ef0f35ca71 --- /dev/null +++ b/packages/fxa-content-server/server/lib/url-scrubber.js @@ -0,0 +1,65 @@ +/* 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 (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} 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) { + 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 isRelative ? parsed.pathname + parsed.search : 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..01c41b00a24 --- /dev/null +++ b/packages/fxa-content-server/server/lib/url-scrubber.test.js @@ -0,0 +1,64 @@ +/* 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 tokens unchanged (e.g. CSP keywords)', () => { + expect(stripPIIFromUrl('inline')).toBe('inline'); + expect(stripPIIFromUrl('eval')).toBe('eval'); + }); + + 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', () => { + // 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..2e48eea3451 --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict-manifest-builder.js @@ -0,0 +1,189 @@ +/* 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'); + +// 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. + * + * @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; +} + +/** + * @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. + * + * 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 = {}; + // Deduplicate potential shared content + 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. + 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) { + // 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 + // 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: by default the file goes to the URL-keyed hashes list. + 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.js b/packages/fxa-content-server/server/lib/waict.js new file mode 100644 index 00000000000..791869f2251 --- /dev/null +++ b/packages/fxa-content-server/server/lib/waict.js @@ -0,0 +1,61 @@ +/* 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. + +'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. +// 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. + * + * @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}"`; + 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(); + }); +}; + +module.exports.buildHeaderValue = buildHeaderValue; +module.exports.REPORT_ENDPOINT_NAME = REPORT_ENDPOINT_NAME; 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 a3700b3d70d..88464a30ccd 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,18 @@ 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. + const waictSidecar = { + baseUrl: process.env.PUBLIC_URL || '', + assets: WAICT_PUBLIC_ASSETS, + }; + fs.writeFileSync( + path.join(paths.appBuild, 'waict-public-assets.json'), + JSON.stringify(waictSidecar, null, 2) + ); + const appPackage = require(paths.appPackageJson); const publicUrl = paths.publicUrlOrPath; const publicPath = config.output.publicPath;