fix(security): prevent caching of sensitive API responses

This commit is contained in:
2026-11-09 16:38:19 +00:00
parent 63c56da143
commit 73419aa598
2 changed files with 249 additions and 1 deletions
+219 -1
View File
@@ -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) {