319 lines
10 KiB
JavaScript
319 lines
10 KiB
JavaScript
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
|
|
.readFileSync(middlewarePath, "utf8")
|
|
.replace(
|
|
'import { NextResponse } from "next/server";',
|
|
'const { NextResponse } = require("next/server");'
|
|
)
|
|
.replace("export function middleware", "function middleware")
|
|
.concat("\nmodule.exports = { middleware };\n");
|
|
const context = {
|
|
Buffer,
|
|
Headers,
|
|
module: { exports: {} },
|
|
process: {
|
|
env: {
|
|
NEXTAUTH_URL: "https://example.test",
|
|
NODE_ENV: "production"
|
|
}
|
|
},
|
|
require: (moduleName) => {
|
|
assert.strictEqual(moduleName, "next/server");
|
|
return {
|
|
NextResponse: {
|
|
next: () => ({ headers: new Headers() })
|
|
}
|
|
};
|
|
},
|
|
crypto: {
|
|
randomUUID: () => "00000000-0000-4000-8000-000000000000"
|
|
}
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: middlewarePath });
|
|
return context.module.exports.middleware;
|
|
};
|
|
|
|
const run = async () => {
|
|
const nextConfig = require(path.join(rootDirectory, "next.config.js"));
|
|
const configuredRules = await nextConfig.headers();
|
|
const globalRule = configuredRules.find(
|
|
({ source }) => source === "/(.*)?"
|
|
);
|
|
|
|
assert.ok(globalRule, "Expected the global Next.js security-header rule");
|
|
assert.deepStrictEqual(toHeaderObject(globalRule.headers), {
|
|
"X-DNS-Prefetch-Control": "on",
|
|
"X-Frame-Options": "SAMEORIGIN",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Permitted-Cross-Domain-Policies": "none",
|
|
"Referrer-Policy": "origin-when-cross-origin",
|
|
"Strict-Transport-Security":
|
|
"max-age=63072000; includeSubDomains; preload",
|
|
"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() });
|
|
|
|
assert.strictEqual(response.headers.get("X-XSS-Protection"), "0");
|
|
assert.strictEqual(response.headers.get("X-Content-Type-Options"), "nosniff");
|
|
assert.strictEqual(response.headers.get("X-Frame-Options"), "DENY");
|
|
assert.strictEqual(
|
|
response.headers.get("Referrer-Policy"),
|
|
"strict-origin-when-cross-origin"
|
|
);
|
|
assert.strictEqual(
|
|
response.headers.get("Permissions-Policy"),
|
|
"geolocation=(), camera=(), microphone=(), fullscreen=(self)"
|
|
);
|
|
assert.strictEqual(
|
|
response.headers.get("Strict-Transport-Security"),
|
|
"max-age=63072000; includeSubDomains; preload"
|
|
);
|
|
assert.match(
|
|
response.headers.get("Content-Security-Policy"),
|
|
/default-src 'self'/
|
|
);
|
|
|
|
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) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = run;
|