Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/fxa-content-server/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
"strict": "off",
"handle-callback-err": "off"
},
"overrides": [
{
"files": ["**/*.test.js"],
"env": {
"jest": true
}
}
],
"ignorePatterns": [
"app/scripts/lib/glean/*.js",
"dist",
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-content-server/grunttasks/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
};
113 changes: 113 additions & 0 deletions packages/fxa-content-server/grunttasks/waict-manifest.js
Original file line number Diff line number Diff line change
@@ -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
);
}
);
};
32 changes: 32 additions & 0 deletions packages/fxa-content-server/jest.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/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'],
};
4 changes: 3 additions & 1 deletion packages/fxa-content-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 23 additions & 8 deletions packages/fxa-content-server/server/bin/fxa-content-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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());
Expand All @@ -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(
Expand Down
60 changes: 36 additions & 24 deletions packages/fxa-content-server/server/lib/beta-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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')
? `<script defer src="${CANARY_PATH}"></script>`
: '';

// Inject the WAICT canary script before </head>, if enabled.
function injectWaictCanary(html) {
if (!waictCanaryTag) {
return html;
}
return html.replace('</head>', waictCanaryTag + '</head>');
}

const preconnectLinks = [];
function preconnect(val) {
if (!val) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
})
)
);
};

Expand Down
38 changes: 38 additions & 0 deletions packages/fxa-content-server/server/lib/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading