diff --git a/next.config.js b/next.config.js index 246c3f45..90d209ea 100644 --- a/next.config.js +++ b/next.config.js @@ -53,6 +53,12 @@ const securityHeaders = [ // value: "frame-src 'self'; child-src 'self';object-src 'none'; script-src 'self' 'https://www.googletagmanager.com' unsafe-inline", // }, ]; +const noStoreHeaders = [ + { + key: "Cache-Control", + value: "no-store" + } +]; //const buildId = nextBuildId.sync({ dir: __dirname }); const buildId = Date.now().toString(); @@ -77,6 +83,30 @@ module.exports = { // Apply these headers to all routes in your application. source: "/(.*)?", headers: securityHeaders + }, + { + source: + "/api/:family(admin|email|file|myportal|subscriptions)/:path*", + headers: noStoreHeaders + }, + { + source: "/api/auth/resolve-locale", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(createaccount_api|getaccounts_api|getemailaccountcheck_api|getpersonalaccount_api|getportallogin_api|getportalloginproxy_api|getpreferredlanguage_api|updateaccount_api|gethash_api|createcrmtask_api)", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(getmycases_api|getmylpacases_api|getmyrepresentations_api|getmyrepresentationsproxy_api|getawaitingsubmission_api|getawaitingsubmissionproxy_api|getwatchedcases_api|getwatchedcasesproxy_api|createwatchedcases_api|deletewatchedcases_api|deletewatchedcasesproxy_api|deleteawaitingsubmissions_api)", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(getbasicpartsaveddetails_api|getpartsavedappeal_api|getappealid_api|getappealpdfdocuments_api|getportalmoduledetails_api|getportalmoduledetailsproxy_api|patchcase_api|updatecase_api)", + headers: noStoreHeaders } ]; }, diff --git a/tests/security/security-headers.test.cjs b/tests/security/security-headers.test.cjs index 160bbeaa..cfe41763 100644 --- a/tests/security/security-headers.test.cjs +++ b/tests/security/security-headers.test.cjs @@ -2,12 +2,59 @@ const assert = require("assert"); const fs = require("fs"); const path = require("path"); const vm = require("vm"); +const { pathToRegexp } = require("next/dist/compiled/path-to-regexp"); +const { + createNextConnectMock, + createRes, + loadModule, + respondErrorMock, + respondSuccessMock +} = require("../phase21/_shared.cjs"); const rootDirectory = path.join(__dirname, "..", ".."); const toHeaderObject = (headers) => Object.fromEntries(headers.map(({ key, value }) => [key, value])); +const getMatchingHeaders = (configuredRules, pathname) => + configuredRules + .filter(({ source }) => pathToRegexp(source).test(pathname)) + .flatMap(({ headers }) => headers); + +const getCacheControl = (configuredRules, pathname) => + getMatchingHeaders(configuredRules, pathname).find( + ({ key }) => key.toLowerCase() === "cache-control" + )?.value; + +const applyConfiguredHeaders = (configuredRules, pathname, res) => { + for (const { key, value } of getMatchingHeaders( + configuredRules, + pathname + )) { + res.setHeader(key, value); + } +}; + +const loadAdminNoStore = () => { + const adminAuthPath = path.join(rootDirectory, "lib", "admin", "adminAuth.js"); + const moduleSource = fs.readFileSync(adminAuthPath, "utf8"); + const declaration = moduleSource.match( + /export const setAdminAuthNoStore = \(res\) => \{[\s\S]*?\n\};/ + ); + + assert.ok(declaration, "Expected to find setAdminAuthNoStore declaration"); + + const source = declaration[0] + .replace("export const", "const") + .concat("\nmodule.exports = { setAdminAuthNoStore };\n"); + const context = { + module: { exports: {} } + }; + + vm.runInNewContext(source, context, { filename: adminAuthPath }); + return context.module.exports.setAdminAuthNoStore; +}; + const loadMiddleware = () => { const middlewarePath = path.join(rootDirectory, "middleware.js"); const source = fs @@ -64,6 +111,81 @@ const run = async () => { "X-XSS-Protection": "0" }); + const sensitivePaths = [ + "/api/admin/auth/session", + "/api/email/notify", + "/api/file/getprogressobjblob", + "/api/file/nested/private/resource", + "/api/myportal/get-my-cases", + "/api/subscriptions/get-watched-cases", + "/api/auth/resolve-locale", + "/api/endpoint/createaccount_api", + "/api/endpoint/getaccounts_api", + "/api/endpoint/getemailaccountcheck_api", + "/api/endpoint/getpersonalaccount_api", + "/api/endpoint/getportallogin_api", + "/api/endpoint/getportalloginproxy_api", + "/api/endpoint/getpreferredlanguage_api", + "/api/endpoint/updateaccount_api", + "/api/endpoint/gethash_api", + "/api/endpoint/getmycases_api", + "/api/endpoint/getmylpacases_api", + "/api/endpoint/getmyrepresentations_api", + "/api/endpoint/getmyrepresentationsproxy_api", + "/api/endpoint/getawaitingsubmission_api", + "/api/endpoint/getawaitingsubmissionproxy_api", + "/api/endpoint/getwatchedcases_api", + "/api/endpoint/getwatchedcasesproxy_api", + "/api/endpoint/createwatchedcases_api", + "/api/endpoint/deletewatchedcases_api", + "/api/endpoint/deletewatchedcasesproxy_api", + "/api/endpoint/deleteawaitingsubmissions_api", + "/api/endpoint/getbasicpartsaveddetails_api", + "/api/endpoint/getpartsavedappeal_api", + "/api/endpoint/getappealid_api", + "/api/endpoint/getappealpdfdocuments_api", + "/api/endpoint/getportalmoduledetails_api", + "/api/endpoint/getportalmoduledetailsproxy_api", + "/api/endpoint/patchcase_api", + "/api/endpoint/updatecase_api", + "/api/endpoint/createcrmtask_api" + ]; + + for (const pathname of sensitivePaths) { + assert.strictEqual( + getCacheControl(configuredRules, pathname), + "no-store", + `Expected ${pathname} to receive Cache-Control: no-store` + ); + } + + const excludedPaths = [ + "/", + "/search", + "/dnsapplications", + "/_next/static/chunks/example.js", + "/_next/image", + "/api/search/advanced", + "/api/cases/get-incident-by-id", + "/api/documents/download/example", + "/api/notices", + "/api/health", + "/api/auth/session", + "/api/auth/csrf", + "/api/endpoint/getbasicsearch_api", + "/api/endpoint/getrepresentations_api", + "/api/endpoint/getappealtypes_api", + "/api/endpoint/getpicklists_api" + ]; + + for (const pathname of excludedPaths) { + assert.strictEqual( + getCacheControl(configuredRules, pathname), + undefined, + `Expected ${pathname} to remain outside the no-store rules` + ); + } + const middleware = loadMiddleware(); const response = middleware({ headers: new Headers() }); @@ -87,7 +209,103 @@ const run = async () => { /default-src 'self'/ ); - console.log("Security-header regression tests passed (2/2)."); + const ordinaryResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/endpoint/getpersonalaccount_api", + ordinaryResponse + ); + respondSuccessMock(ordinaryResponse, { ok: true }); + assert.strictEqual( + ordinaryResponse.state.headers["cache-control"], + "no-store" + ); + + const validationErrorResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/endpoint/getpersonalaccount_api", + validationErrorResponse + ); + respondErrorMock(validationErrorResponse, { + status: 400, + code: "CONTACT_ID_REQUIRED", + message: "contactid is required" + }); + assert.strictEqual( + validationErrorResponse.state.headers["cache-control"], + "no-store" + ); + + const adminResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/admin/auth/session", + adminResponse + ); + loadAdminNoStore()(adminResponse); + assert.strictEqual( + adminResponse.state.headers["cache-control"], + "no-store, max-age=0" + ); + assert.strictEqual(adminResponse.state.headers.pragma, "no-cache"); + + const fileHandler = loadModule("pages/api/file/getrepsblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + getServerSession: async () => ({ user: { id: "owner-container" } }), + authOptions: () => ({}), + readAdminSessionCookie: () => null, + validateAdminSession: () => false, + getRepsBlobs: async () => [{ name: "representation.json" }], + downloadAllRepsFiles: async () => [{ id: "representation" }], + consoleLogger: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const fileResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/file/getrepsblob", + fileResponse + ); + await fileHandler.default.handler( + { + query: { + container: "owner-container", + hash: "expected" + } + }, + fileResponse + ); + assert.strictEqual(fileResponse.state.statusCode, 200); + assert.strictEqual( + fileResponse.state.headers["cache-control"], + "no-store" + ); + + const publicCacheDirective = + "public, s-maxage=10, stale-while-revalidate=59"; + for (const relativePath of [ + "pages/dns/index.js", + "pages/dnsapplications.js", + "pages/myportal/dnsapplications.js" + ]) { + const pageSource = fs.readFileSync( + path.join(rootDirectory, relativePath), + "utf8" + ); + assert.match( + pageSource, + new RegExp( + `res\\.setHeader\\(\\s*["']Cache-Control["']\\s*,\\s*["']${publicCacheDirective.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*\\)` + ), + `Expected ${relativePath} to preserve its public cache directive` + ); + } + + console.log("Security-header regression tests passed (10/10)."); }; if (require.main === module) {