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 85dfc290630..bd2f1fc5fa7 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -59,8 +59,10 @@ const fourOhFour = require('../lib/404'); const serverErrorHandler = require('../lib/500'); const localizedRender = require('../lib/localized-render'); const csp = require('../lib/csp'); -const cspRulesBlocking = require('../lib/csp/blocking')(config); -const cspRulesReportOnly = require('../lib/csp/report-only')(config); +const cspRulesBlocking = require('../../../fxa-shared/csp/blocking')(config); +const cspRulesReportOnly = require('../../../fxa-shared/csp/report-only')( + config +); const STATIC_DIRECTORY = path.join( __dirname, diff --git a/packages/fxa-content-server/tests/server/csp.js b/packages/fxa-content-server/tests/server/csp.js index 2996b828e13..c05e901628f 100644 --- a/packages/fxa-content-server/tests/server/csp.js +++ b/packages/fxa-content-server/tests/server/csp.js @@ -5,8 +5,8 @@ const { registerSuite } = intern.getInterface('object'); const assert = intern.getPlugin('chai').assert; const config = require('../../server/lib/configuration'); -const BlockingRules = require('../../server/lib/csp/blocking'); const path = require('path'); +const blockingRules = require('../../../fxa-shared/csp/blocking'); const proxyquire = require('proxyquire'); const csp = proxyquire(path.join(process.cwd(), 'server', 'lib', 'csp'), { @@ -34,15 +34,15 @@ suite.tests['blockingRules'] = function() { const CDN_SERVER = 'https://static.accounts.firefox.com'; config.set('static_resource_url', CDN_SERVER); - const blockingRules = BlockingRules(config); - const Sources = blockingRules.Sources; + const blockingRulesWithConfig = blockingRules(config); + const Sources = blockingRulesWithConfig.Sources; // Ensure none of the Sources map to `undefined`G assert.notProperty(Sources, 'undefined'); - assert.isFalse(blockingRules.reportOnly); + assert.isFalse(blockingRulesWithConfig.reportOnly); - const directives = blockingRules.directives; + const directives = blockingRulesWithConfig.directives; const connectSrc = directives.connectSrc; assert.lengthOf(connectSrc, 7); diff --git a/packages/fxa-payments-server/server/config/index.js b/packages/fxa-payments-server/server/config/index.js index 0cfff37fe92..adae8ab8ca1 100644 --- a/packages/fxa-payments-server/server/config/index.js +++ b/packages/fxa-payments-server/server/config/index.js @@ -2,182 +2,222 @@ * 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'; +"use strict"; -const fs = require('fs'); -const path = require('path'); -const convict = require('convict'); +const fs = require("fs"); +const path = require("path"); +const convict = require("convict"); const conf = convict({ clientAddressDepth: { default: 3, - doc: 'location of the client ip address in the remote address chain', - env: 'CLIENT_ADDRESS_DEPTH', + doc: "location of the client ip address in the remote address chain", + env: "CLIENT_ADDRESS_DEPTH", format: Number }, + csp: { + enabled: { + default: false, + doc: 'Send "Content-Security-Policy" header', + env: "CSP_ENABLED" + }, + /*eslint-disable sorting/sort-object-props*/ + reportUri: { + default: "/_/csp-violation", + doc: 'Location of "report-uri" for full, blocking CSP rules', + env: "CSP_REPORT_URI" + }, + reportOnly: { + default: false, + doc: + 'DEPRECATED - Only send the "Content-Security-Policy-Report-Only" header', + env: "CSP_REPORT_ONLY" + }, + reportOnlyEnabled: { + default: false, + doc: 'Send "Content-Security-Policy-Report-Only" header', + env: "CSP_REPORT_ONLY_ENABLED" + }, + reportOnlyUri: { + default: "/_/csp-violation-report-only", + doc: 'Location of "report-uri" for report-only CSP rules', + env: "CSP_REPORT_ONLY_URI" + } + /*eslint-enable sorting/sort-object-props*/ + }, env: { - default: 'production', - doc: 'The current node.js environment', - env: 'NODE_ENV', - format: [ 'development', 'production' ], + default: "production", + doc: "The current node.js environment", + env: "NODE_ENV", + format: ["development", "production"] }, hstsMaxAge: { default: 31536000, // a year - doc: 'Max age of the STS directive in seconds', + doc: "Max age of the STS directive in seconds", // Note: This format is a number because the value needs to be in seconds format: Number }, listen: { host: { - default: '127.0.0.1', - doc: 'The ip address the server should bind', - env: 'IP_ADDRESS', - format: 'ipaddress', + default: "127.0.0.1", + doc: "The ip address the server should bind", + env: "IP_ADDRESS", + format: "ipaddress" }, port: { default: 3031, - doc: 'The port the server should bind', - env: 'PORT', - format: 'port', + doc: "The port the server should bind", + env: "PORT", + format: "port" }, publicUrl: { - default: 'http://127.0.0.1:3031', - env: 'PUBLIC_URL', - format: 'url', + default: "http://127.0.0.1:3031", + env: "PUBLIC_URL", + format: "url" }, useHttps: { default: false, - doc: 'set to true to serve directly over https', - env: 'USE_TLS', - }, + doc: "set to true to serve directly over https", + env: "USE_TLS" + } }, logging: { - app: { default: 'fxa-payments-server' }, + app: { default: "fxa-payments-server" }, fmt: { - default: 'heka', - env: 'LOGGING_FORMAT', - format: [ - 'heka', - 'pretty' - ], + default: "heka", + env: "LOGGING_FORMAT", + format: ["heka", "pretty"] }, level: { - default: 'info', - env: 'LOG_LEVEL' + default: "info", + env: "LOG_LEVEL" }, routes: { enabled: { default: true, - doc: 'Enable route logging. Set to false to trimming CI logs.', - env: 'ENABLE_ROUTE_LOGGING' + doc: "Enable route logging. Set to false to trimming CI logs.", + env: "ENABLE_ROUTE_LOGGING" }, format: { - default: 'default_fxa', - format: [ - 'default_fxa', - 'dev_fxa', - 'default', - 'dev', - 'short', - 'tiny' - ] - }, - }, + default: "default_fxa", + format: ["default_fxa", "dev_fxa", "default", "dev", "short", "tiny"] + } + } }, productRedirectURLs: { default: { - '123doneProProduct': 'http://127.0.0.1:8080/', - 'prod_Ex9Z1q5yVydhyk': 'https://123done-latest.dev.lcip.org/', - 'prod_FUUNYnlDso7FeB': 'https://123done-stage.dev.lcip.org', + "123doneProProduct": "http://127.0.0.1:8080/", + prod_Ex9Z1q5yVydhyk: "https://123done-latest.dev.lcip.org/", + prod_FUUNYnlDso7FeB: "https://123done-stage.dev.lcip.org" }, - doc: 'Mapping between product IDs and post-subscription redirect URLs', - env: 'PRODUCT_REDIRECT_URLS', - format: Object, + doc: "Mapping between product IDs and post-subscription redirect URLs", + env: "PRODUCT_REDIRECT_URLS", + format: Object }, proxyStaticResourcesFrom: { - default: '', - doc: 'Instead of loading static resources from disk, get them by proxy from this URL (typically a special reloading dev server)', - env: 'PROXY_STATIC_RESOURCES_FROM', - format: String, + default: "", + doc: + "Instead of loading static resources from disk, get them by proxy from this URL (typically a special reloading dev server)", + env: "PROXY_STATIC_RESOURCES_FROM", + format: String }, sentryDsn: { - default: '', - doc: 'Sentry DSN', - env: 'SENTRY_DSN', - format: 'String', + default: "", + doc: "Sentry DSN", + env: "SENTRY_DSN", + format: "String" }, servers: { auth: { url: { - default: 'http://127.0.0.1:9000', - doc: 'The url of the fxa-auth-server instance', - env: 'AUTH_SERVER_URL', - format: 'url', + default: "http://127.0.0.1:9000", + doc: "The url of the fxa-auth-server instance", + env: "AUTH_SERVER_URL", + format: "url" } }, content: { url: { - default: 'http://127.0.0.1:3030', - doc: 'The url of the corresponding fxa-content-server instance', - env: 'CONTENT_SERVER_URL', - format: 'url', + default: "http://127.0.0.1:3030", + doc: "The url of the corresponding fxa-content-server instance", + env: "CONTENT_SERVER_URL", + format: "url" } }, oauth: { url: { - default: 'http://127.0.0.1:9010', - doc: 'The url of the corresponding fxa-oauth-server instance', - env: 'OAUTH_SERVER_URL', - format: 'url', + default: "http://127.0.0.1:9010", + doc: "The url of the corresponding fxa-oauth-server instance", + env: "OAUTH_SERVER_URL", + format: "url" } }, profile: { url: { - default: 'http://127.0.0.1:1111', - doc: 'The url of the corresponding fxa-profile-server instance', - env: 'PROFILE_SERVER_URL', - format: 'url', + default: "http://127.0.0.1:1111", + doc: "The url of the corresponding fxa-profile-server instance", + env: "PROFILE_SERVER_URL", + format: "url" } - }, + } }, staticResources: { directory: { - default: 'build', - doc: 'Directory where static resources are located', - env: 'STATIC_DIRECTORY', - format: String, + default: "build", + doc: "Directory where static resources are located", + env: "STATIC_DIRECTORY", + format: String }, maxAge: { - default: '10 minutes', - doc: 'Cache max age for static assets, in ms', - env: 'STATIC_MAX_AGE', - format: 'duration' + default: "10 minutes", + doc: "Cache max age for static assets, in ms", + env: "STATIC_MAX_AGE", + format: "duration" }, url: { - default: 'http://127.0.0.1:3031', - doc: 'The origin of the static resources', - env: 'STATIC_RESOURCE_URL', - format: 'url' + default: "http://127.0.0.1:3031", + doc: "The origin of the static resources", + env: "STATIC_RESOURCE_URL", + format: "url" } }, stripe: { apiKey: { - default: 'pk_test_FL2cOisOukoCQUZsrochvTlk00ff4IakfE', - doc: 'API key for Stripe', - env: 'STRIPE_API_KEY', - format: String, + default: "pk_test_FL2cOisOukoCQUZsrochvTlk00ff4IakfE", + doc: "API key for Stripe", + env: "STRIPE_API_KEY", + format: String } - }, + } }); +// Always send CSP headers in development mode +if (conf.get("env") === "development") { + conf.set("csp.enabled", true); +} + +// TO DO: remove these - gross +// if (! conf.has('fxaccount_url')) { +// conf.set('fxaccount_url', conf.get('servers.auth.url')); +// } +// if (! conf.has('static_resource_url')) { +// conf.set('static_resource_url', conf.get('staticResources.url')); +// } +// if (! conf.has('oauth_url')) { +// conf.set('static_resource_url', conf.get('servers.oauth.url')); +// } +// if (! conf.has('profile_url')) { +// conf.set('static_resource_url', conf.get('servers.oauth.url')); +// } + // handle configuration files. you can specify a CSV list of configuration // files to process, which will be overlayed in order, in the CONFIG_FILES // environment variable. -let envConfig = path.join(__dirname, `${conf.get('env') }.json`); -envConfig = `${envConfig },${ process.env.CONFIG_FILES || ''}`; -const files = envConfig.split(',').filter(fs.existsSync); +let envConfig = path.join(__dirname, `${conf.get("env")}.json`); +envConfig = `${envConfig},${process.env.CONFIG_FILES || ""}`; +const files = envConfig.split(",").filter(fs.existsSync); conf.loadFile(files); -conf.validate({ allowed: 'strict' }); +conf.validate({ allowed: "strict" }); module.exports = conf; diff --git a/packages/fxa-payments-server/server/lib/csp.js b/packages/fxa-payments-server/server/lib/csp.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/fxa-payments-server/server/lib/server.js b/packages/fxa-payments-server/server/lib/server.js index 504cfb830a0..6a6d53d383a 100644 --- a/packages/fxa-payments-server/server/lib/server.js +++ b/packages/fxa-payments-server/server/lib/server.js @@ -2,62 +2,65 @@ * 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'; +"use strict"; module.exports = () => { - const path = require('path'); - const fs = require('fs'); + const path = require("path"); + const fs = require("fs"); // setup version first for the rest of the modules - const logger = require('./logging/log')('server.main'); - const version = require('./version'); - const config = require('../config'); + const logger = require("./logging/log")("server.main"); + const version = require("./version"); + const config = require("../config"); logger.info(`source set to: ${version.source}`); logger.info(`version set to: ${version.version}`); logger.info(`commit hash set to: ${version.commit}`); - const express = require('express'); - const helmet = require('helmet'); - const sentry = require('@sentry/node'); - const serveStatic = require('serve-static'); + const express = require("express"); + const helmet = require("helmet"); + const sentry = require("@sentry/node"); + const serveStatic = require("serve-static"); + + const bodyParser = require("body-parser"); + const csp = require("../lib/csp"); + const cspRulesBlocking = require("../../../fxa-shared/csp/blocking")(config); + const cspRulesReportOnly = require("../../../fxa-shared/csp/report-only")( + config + ); const app = express(); // Each of these config values (e.g., 'servers.content') will be exposed as the given // variable to the client/browser (via fxa-content-server/config) const CLIENT_CONFIG = { - productRedirectURLs: config.get('productRedirectURLs'), - sentryDsn: config.get('sentryDsn'), + productRedirectURLs: config.get("productRedirectURLs"), + sentryDsn: config.get("sentryDsn"), servers: { auth: { - url: config.get('servers.auth.url'), + url: config.get("servers.auth.url") }, content: { - url: config.get('servers.content.url'), + url: config.get("servers.content.url") }, oauth: { - url: config.get('servers.oauth.url'), + url: config.get("servers.oauth.url") }, profile: { - url: config.get('servers.profile.url'), - }, + url: config.get("servers.profile.url") + } }, stripe: { - apiKey: config.get('stripe.apiKey'), - }, + apiKey: config.get("stripe.apiKey") + } }; // This is a list of all the paths that should resolve to index.html: - const INDEX_ROUTES = [ - '/', - '/subscriptions', - '/products/:productId', - ]; + const INDEX_ROUTES = ["/", "/subscriptions", "/products/:productId"]; - app.disable('x-powered-by'); + app.disable("x-powered-by"); - const sentryDsn = config.get('sentryDsn'); + const sentryDsn = config.get("sentryDsn"); if (sentryDsn) { sentry.init({ dsn: sentryDsn }); app.use(sentry.Handlers.requestHandler()); @@ -65,10 +68,10 @@ module.exports = () => { app.use( // Side effect - Adds default_fxa and dev_fxa to express.logger formats - require('./logging/route-logging')(), + require("./logging/route-logging")(), helmet.frameguard({ - action: 'deny' + action: "deny" }), helmet.xssFilter(), @@ -76,97 +79,140 @@ module.exports = () => { helmet.hsts({ force: true, includeSubDomains: true, - maxAge: config.get('hstsMaxAge') + maxAge: config.get("hstsMaxAge") }), helmet.noSniff(), - // TODO CSP - require('./no-robots') + require("./no-robots"), + + bodyParser.text({ + type: "text/plain" + }), + + bodyParser.json({ + // the 3 entries: + // json file types, + // all json content-types + // csp reports + type: ["json", "*/json", "application/csp-report"] + }) ); + if (config.get("csp.enabled")) { + app.use(csp({ rules: cspRulesBlocking })); + } + if (config.get("csp.reportOnlyEnabled")) { + // There has to be more than a `reportUri` + // to enable reportOnly CSP. + if (Object.keys(cspRulesReportOnly.directives).length > 1) { + app.use(csp({ rules: cspRulesReportOnly })); + } + } + if (isCorsRequired()) { // JS, CSS and web font resources served from a CDN // will be ignored unless CORS headers are present. const corsOptions = { - origin: config.get('public_url') + origin: config.get("public_url") }; - app.route(/\.(js|css|woff|woff2|eot|ttf)$/) - .get(require('cors')(corsOptions)); + app + .route(/\.(js|css|woff|woff2|eot|ttf)$/) + .get(require("cors")(corsOptions)); } function injectHtmlConfig(html, config, featureFlags) { const encodedConfig = encodeURIComponent(JSON.stringify(config)); - let result = html.replace('__SERVER_CONFIG__', encodedConfig); - const encodedFeatureFlags = encodeURIComponent(JSON.stringify(featureFlags)); - result = result.replace('__FEATURE_FLAGS__', encodedFeatureFlags); + let result = html.replace("__SERVER_CONFIG__", encodedConfig); + const encodedFeatureFlags = encodeURIComponent( + JSON.stringify(featureFlags) + ); + result = result.replace("__FEATURE_FLAGS__", encodedFeatureFlags); return result; } - const proxyUrl = config.get('proxyStaticResourcesFrom'); + const proxyUrl = config.get("proxyStaticResourcesFrom"); if (proxyUrl) { - logger.info('static.proxying', { url: proxyUrl }); - const proxy = require('express-http-proxy'); - app.use('/', proxy(proxyUrl, { - userResDecorator: function(proxyRes, proxyResData, userReq/*, userRes*/) { - const contentType = proxyRes.headers['content-type']; - if (! contentType || ! contentType.startsWith('text/html')) { - return proxyResData; + logger.info("static.proxying", { url: proxyUrl }); + const proxy = require("express-http-proxy"); + app.use( + "/", + proxy(proxyUrl, { + userResDecorator: function( + proxyRes, + proxyResData, + userReq /*, userRes*/ + ) { + const contentType = proxyRes.headers["content-type"]; + if (!contentType || !contentType.startsWith("text/html")) { + return proxyResData; + } + if (userReq.url.startsWith("/sockjs-node/")) { + // This is a development WebPack channel that we don't want to modify + return proxyResData; + } + const body = proxyResData.toString("utf8"); + return injectHtmlConfig(body, CLIENT_CONFIG, {}); } - if (userReq.url.startsWith('/sockjs-node/')) { - // This is a development WebPack channel that we don't want to modify - return proxyResData; - } - const body = proxyResData.toString('utf8'); - return injectHtmlConfig(body, CLIENT_CONFIG, {}); - } - })); + }) + ); } else { - const STATIC_DIRECTORY = - path.join(__dirname, '..', '..', config.get('staticResources.directory')); - - logger.info('static.directory', { directory: STATIC_DIRECTORY }); - - const STATIC_INDEX_HTML = - fs.readFileSync(path.join(STATIC_DIRECTORY, 'index.html'), {encoding: 'UTF-8'}); - - const renderedStaticHtml = injectHtmlConfig(STATIC_INDEX_HTML, CLIENT_CONFIG, {}); + const STATIC_DIRECTORY = path.join( + __dirname, + "..", + "..", + config.get("staticResources.directory") + ); + + logger.info("static.directory", { directory: STATIC_DIRECTORY }); + + const STATIC_INDEX_HTML = fs.readFileSync( + path.join(STATIC_DIRECTORY, "index.html"), + { encoding: "UTF-8" } + ); + + const renderedStaticHtml = injectHtmlConfig( + STATIC_INDEX_HTML, + CLIENT_CONFIG, + {} + ); for (const route of INDEX_ROUTES) { // FIXME: should set ETag, Not-Modified: app.get(route, (req, res) => { res.send(renderedStaticHtml); }); } - app.use(serveStatic(STATIC_DIRECTORY, { - maxAge: config.get('staticResources.maxAge') - })); + app.use( + serveStatic(STATIC_DIRECTORY, { + maxAge: config.get("staticResources.maxAge") + }) + ); } - app.get('/__lbheartbeat__', (req, res) => { - res.type('txt').send('Ok'); + app.get("/__lbheartbeat__", (req, res) => { + res.type("txt").send("Ok"); }); // it's a four-oh-four not found. - app.use(require('./404')); + app.use(require("./404")); if (sentryDsn) { app.use(sentry.Handlers.errorHandler()); } - function listen () { - const port = config.get('listen.port'); - const host = config.get('listen.host'); - logger.info('server.starting', { port }); - app.listen(port, host, (error) => { + function listen() { + const port = config.get("listen.port"); + const host = config.get("listen.host"); + logger.info("server.starting", { port }); + app.listen(port, host, error => { if (error) { - logger.error('server.start.error', { error }); + logger.error("server.start.error", { error }); return; } - logger.info('server.started', { port }); + logger.info("server.started", { port }); }); - } return { @@ -174,7 +220,6 @@ module.exports = () => { }; function isCorsRequired() { - return config.get('staticResources.url') !== config.get('listen.publicUrl'); + return config.get("staticResources.url") !== config.get("listen.publicUrl"); } - }; diff --git a/packages/fxa-shared/README.md b/packages/fxa-shared/README.md index 9698708cd09..3139d7e139d 100644 --- a/packages/fxa-shared/README.md +++ b/packages/fxa-shared/README.md @@ -2,6 +2,10 @@ ## Shared modules +### CSP + +`csp/` contains shared CSP files across `fxa-content-server` and `fxa-payment-server`. + ### l10n `supportedLanguages.json` is the shared list of all supported locales across FxA diff --git a/packages/fxa-content-server/server/lib/csp/blocking.js b/packages/fxa-shared/csp/blocking.js similarity index 89% rename from packages/fxa-content-server/server/lib/csp/blocking.js rename to packages/fxa-shared/csp/blocking.js index 98cc7df2e15..8774430a3e6 100644 --- a/packages/fxa-content-server/server/lib/csp/blocking.js +++ b/packages/fxa-shared/csp/blocking.js @@ -19,7 +19,18 @@ function getOrigin(link) { * to be blocked if it runs afowl of a rule. */ module.exports = function(config) { + // fail const AUTH_SERVER = getOrigin(config.get('fxaccount_url')); + // const AUTH_SERVER = getOrigin(config.get('fxaccount_url')) || getOrigin(config.get('servers.auth.url'); + + // works + let authServer; + try { + authServer = getOrigin(config.get('fxaccount_url')); + } catch (e) { + authServer = getOrigin(config.get('servers.auth.url')); + } + const BLOB = 'blob:'; const CDN_URL = config.get('static_resource_url'); const DATA = 'data:'; @@ -53,6 +64,7 @@ module.exports = function(config) { } const rules = { + // TO DO: add https://stripe.com/docs/security#content-security-policy directives: { connectSrc: [ SELF, diff --git a/packages/fxa-content-server/server/lib/csp/report-only.js b/packages/fxa-shared/csp/reportOnly.js similarity index 100% rename from packages/fxa-content-server/server/lib/csp/report-only.js rename to packages/fxa-shared/csp/reportOnly.js diff --git a/packages/fxa-shared/index.js b/packages/fxa-shared/index.js index 182046f816b..a87a9b4ca1d 100644 --- a/packages/fxa-shared/index.js +++ b/packages/fxa-shared/index.js @@ -5,6 +5,11 @@ 'use strict'; module.exports = { + // this can be removed if #1860 is landed + // csp: { + // blocking: require('./csp/blocking'), + // reportOnly: require('./csp/reportOnly'), + // }, email: { popularDomains: require('./email/popularDomains'), },