fix: enforce representation blob ownership and secure SSR access
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { getJson, requestJson } from "./endpointClient";
|
||||
import { getSignedJson, postSignedJson } from "./signedRequestClient";
|
||||
|
||||
export const getFileJson = (url) => {
|
||||
return getJson(url);
|
||||
export const getFileJson = (url, config) => {
|
||||
return getJson(url, config);
|
||||
};
|
||||
|
||||
export const getSignedFileJson = async (queryUrl) => {
|
||||
|
||||
@@ -28,13 +28,48 @@ export const getAwaitingSubmissionFromBlob = (containerName) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getRepsFromBlob = (containerName) => {
|
||||
export const getRepsFromBlob = (containerName, config) => {
|
||||
const route = buildFileQuery("/api/file/getrepsblob", {
|
||||
container: containerName
|
||||
});
|
||||
|
||||
const url = withBaseUrl(BASE_URL, appendHashSuffix(route, hashAPIPath));
|
||||
|
||||
const hasForwardedCookie =
|
||||
typeof config?.headers?.Cookie === "string" &&
|
||||
config.headers.Cookie.length > 0;
|
||||
|
||||
if (hasForwardedCookie) {
|
||||
let trustedDestination = false;
|
||||
|
||||
try {
|
||||
const destination = new URL(url);
|
||||
const expectedOrigin = new URL(process.env.NEXTAUTH_URL);
|
||||
|
||||
trustedDestination =
|
||||
destination.origin === expectedOrigin.origin &&
|
||||
["http:", "https:"].includes(destination.protocol) &&
|
||||
!destination.username &&
|
||||
!destination.password;
|
||||
} catch {
|
||||
trustedDestination = false;
|
||||
}
|
||||
|
||||
if (!trustedDestination) {
|
||||
return Promise.reject(
|
||||
new Error("Untrusted representation request destination")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getFileJson(
|
||||
withBaseUrl(BASE_URL, appendHashSuffix(route, hashAPIPath))
|
||||
url,
|
||||
hasForwardedCookie
|
||||
? {
|
||||
...config,
|
||||
maxRedirects: 0
|
||||
}
|
||||
: config
|
||||
).catch((error) => {
|
||||
consoleLogger(error);
|
||||
});
|
||||
|
||||
@@ -120,7 +120,9 @@ export const loadRepresentationBootstrap = async ({ ctx }) => {
|
||||
|
||||
const [accountDetails, myRepresentations] = await Promise.all([
|
||||
getPersonalAccount(loggedInUser),
|
||||
getRepsFromBlob(thisSession.user.id)
|
||||
getRepsFromBlob(thisSession.user.id, {
|
||||
headers: { Cookie: ctx.req?.headers?.cookie || "" }
|
||||
})
|
||||
]);
|
||||
|
||||
const myRepresentationsDetails = await getDetails(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BASE_URL } from "../../../actions/core/env";
|
||||
import { getToken } from "../../../actions/core/token";
|
||||
import { azureHeaders } from "../../../actions/core/headers";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import axios from "axios";
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
hasTrustedSameOrigin,
|
||||
readAdminSessionCookie,
|
||||
setAdminAuthNoStore,
|
||||
@@ -11,7 +13,16 @@ import {
|
||||
} from "../../../lib/admin/adminAuth";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
|
||||
const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`;
|
||||
const isTrustedInternalDestination = () => {
|
||||
try {
|
||||
return (
|
||||
new URL(BASE_URL).origin ===
|
||||
new URL(process.env.NEXTAUTH_URL).origin
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
@@ -39,10 +50,7 @@ export default async function handler(req, res) {
|
||||
}
|
||||
|
||||
const adminSession = readAdminSessionCookie(req);
|
||||
if (
|
||||
!validateAdminSession(adminSession) ||
|
||||
!hasTrustedSameOrigin(req)
|
||||
) {
|
||||
if (!validateAdminSession(adminSession) || !hasTrustedSameOrigin(req)) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
@@ -55,14 +63,27 @@ export default async function handler(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTrustedInternalDestination()) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const queryUrl =
|
||||
"/api/file/getrepsblob?container=" + encodeURIComponent(container);
|
||||
|
||||
try {
|
||||
const config = azureHeaders(token.access_token);
|
||||
|
||||
const { data } = await axios.get(
|
||||
BASE_URL + queryUrl + hashAPIPath(queryUrl),
|
||||
azureHeaders(token.access_token)
|
||||
{
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
Cookie: `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(adminSession)}`
|
||||
},
|
||||
maxRedirects: 0
|
||||
}
|
||||
);
|
||||
return respondSuccess(res, data);
|
||||
} catch (error) {
|
||||
@@ -73,4 +94,4 @@ export default async function handler(req, res) {
|
||||
message: "Failed to fetch representation blobs"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,23 @@ import middleware from "../middleware/middleware";
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
import {
|
||||
readAdminSessionCookie,
|
||||
validateAdminSession
|
||||
} from "../../../lib/admin/adminAuth";
|
||||
|
||||
const ApiProxy = nextConnect();
|
||||
ApiProxy.use(middleware);
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const checkHash = req.query.hash;
|
||||
@@ -59,6 +72,17 @@ ApiProxy.get(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions(req, res));
|
||||
const isPortalOwner =
|
||||
typeof session?.user?.id === "string" &&
|
||||
session.user.id.length > 0 &&
|
||||
session.user.id === containerName;
|
||||
const isAdministrator = validateAdminSession(readAdminSessionCookie(req));
|
||||
|
||||
if (!isPortalOwner && !isAdministrator) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const hashCandidatePaths = [
|
||||
"/api/file/getrepsblob?container=" + containerName,
|
||||
"/api/file/getrepsblob?container=" + encodeURIComponent(containerName)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BASE_URL } from "../../../actions/core/env";
|
||||
import { getToken } from "../../../actions/core/token";
|
||||
import { azureHeaders } from "../../../actions/core/headers";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
@@ -7,7 +8,16 @@ import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`;
|
||||
const isTrustedInternalDestination = () => {
|
||||
try {
|
||||
return (
|
||||
new URL(BASE_URL).origin ===
|
||||
new URL(process.env.NEXTAUTH_URL).origin
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
@@ -60,15 +70,28 @@ export default async function ApiProxy(req, res) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
if (!isTrustedInternalDestination()) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
const queryUrl =
|
||||
"/api/file/getrepsblob?container=" + encodeURIComponent(containerName);
|
||||
|
||||
try {
|
||||
const config = azureHeaders(token.access_token);
|
||||
|
||||
const { data } = await axios.get(
|
||||
BASE_URL + queryUrl + hashAPIPath(queryUrl),
|
||||
azureHeaders(token.access_token)
|
||||
{
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
Cookie: req.headers.cookie || ""
|
||||
},
|
||||
maxRedirects: 0
|
||||
}
|
||||
);
|
||||
return respondSuccess(res, data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -304,7 +304,8 @@ export const getServerSideProps = wrapper.getServerSideProps(
|
||||
break;
|
||||
case "myRepresentations":
|
||||
const myRepresentations = await getRepsFromBlob(
|
||||
thisSession.user.id
|
||||
thisSession.user.id,
|
||||
{ headers: { Cookie: req.headers.cookie || "" } }
|
||||
);
|
||||
|
||||
const myRepresentationsDetails = await getDetails(
|
||||
|
||||
@@ -293,7 +293,9 @@ export const getServerSideProps = wrapper.getServerSideProps(
|
||||
awaitingSubmissionFromBlob
|
||||
] = await Promise.all([
|
||||
isLPA ? getMyLPACases(lpaId) : getMyCases(loggedInUser),
|
||||
getRepsFromBlob(thisSession.user.id),
|
||||
getRepsFromBlob(thisSession.user.id, {
|
||||
headers: { Cookie: req.headers.cookie || "" }
|
||||
}),
|
||||
getWatchedCases(loggedInUser),
|
||||
getAwaitingSubmissionFromBlob(thisSession.user.id)
|
||||
]);
|
||||
|
||||
@@ -371,7 +371,8 @@ export const getServerSideProps = wrapper.getServerSideProps(
|
||||
|
||||
case "myRepresentations": {
|
||||
const myRepresentations = await getRepsFromBlob(
|
||||
thisSession.user.id
|
||||
thisSession.user.id,
|
||||
{ headers: { Cookie: ctx.req?.headers?.cookie || "" } }
|
||||
);
|
||||
|
||||
// The existing View All component reads
|
||||
|
||||
@@ -50,6 +50,7 @@ const loadModule = (relativePath, injected = {}) => {
|
||||
exports: {},
|
||||
require,
|
||||
process,
|
||||
URL,
|
||||
console: {
|
||||
log: () => {},
|
||||
info: () => {},
|
||||
|
||||
@@ -1554,6 +1554,10 @@ test("getrepsblob handler success returns representations payload", async () =>
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getServerSession: async () => ({ user: { id: "c1" } }),
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () => null,
|
||||
validateAdminSession: () => false,
|
||||
getRepsBlobs: async () => [
|
||||
{ path: "c/ref_rep.json", name: "ref_rep.json" }
|
||||
],
|
||||
@@ -1612,6 +1616,10 @@ test("getrepsblob handler rejects invalid HMAC before Azure access", async () =>
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getServerSession: async () => ({ user: { id: "c1" } }),
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () => null,
|
||||
validateAdminSession: () => false,
|
||||
getRepsBlobs: async () => {
|
||||
azureCalls += 1;
|
||||
return [];
|
||||
@@ -1817,6 +1825,10 @@ test("getrepsblob handler dependency failure returns GET_REPS_BLOB_FAILED", asyn
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getServerSession: async () => ({ user: { id: "c1" } }),
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () => null,
|
||||
validateAdminSession: () => false,
|
||||
getRepsBlobs: async () => {
|
||||
throw new Error("storage failure");
|
||||
},
|
||||
@@ -1844,6 +1856,10 @@ test("getrepsblob handler accepts encoded container hash variant", async () => {
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getServerSession: async () => ({ user: { id: "cont/1" } }),
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () => null,
|
||||
validateAdminSession: () => false,
|
||||
getRepsBlobs: async () => [{ path: "cont/rep1.json" }],
|
||||
downloadAllRepsFiles: async () => ({ value: [{ id: "r-encoded" }] })
|
||||
});
|
||||
@@ -2869,7 +2885,13 @@ const loadOrdinaryRepsProxy = (overrides = {}) => {
|
||||
return "&hash=abc";
|
||||
},
|
||||
consoleLogger: () => {},
|
||||
process: { env: { API_ROOT: "http://example.com" } },
|
||||
BASE_URL: "http://example.com",
|
||||
process: {
|
||||
env: {
|
||||
API_ROOT: "http://example.com",
|
||||
NEXTAUTH_URL: "http://example.com"
|
||||
}
|
||||
},
|
||||
axios: {
|
||||
get: async (...args) => {
|
||||
calls.axios.push(args);
|
||||
@@ -2979,7 +3001,9 @@ for (const containerCase of [
|
||||
|
||||
test("getrepsblobproxy preserves signed path and response for owning user", async () => {
|
||||
const loaded = loadOrdinaryRepsProxy();
|
||||
const req = ordinaryRepsRequest();
|
||||
const req = ordinaryRepsRequest({
|
||||
headers: { cookie: "next-auth.session-token=test-session" }
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default(req, res);
|
||||
@@ -2995,6 +3019,16 @@ test("getrepsblobproxy preserves signed path and response for owning user", asyn
|
||||
loaded.calls.axios[0][0],
|
||||
"http://example.com/api/file/getrepsblob?container=user-container&hash=abc"
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
Cookie: "next-auth.session-token=test-session"
|
||||
},
|
||||
maxRedirects: 0
|
||||
}
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
||||
loaded.payload
|
||||
@@ -3030,6 +3064,7 @@ const loadAdminRepsProxy = (overrides = {}) => {
|
||||
const mod = loadModule("pages/api/admin/getrepsblobproxy.js", {
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
ADMIN_SESSION_COOKIE: "pedw_admin_session",
|
||||
readAdminSessionCookie: () => "admin-token",
|
||||
validateAdminSession: () => true,
|
||||
hasTrustedSameOrigin: () => true,
|
||||
@@ -3044,7 +3079,13 @@ const loadAdminRepsProxy = (overrides = {}) => {
|
||||
return "&hash=abc";
|
||||
},
|
||||
consoleLogger: () => {},
|
||||
process: { env: { API_ROOT: "http://example.com" } },
|
||||
BASE_URL: "http://example.com",
|
||||
process: {
|
||||
env: {
|
||||
API_ROOT: "http://example.com",
|
||||
NEXTAUTH_URL: "http://example.com"
|
||||
}
|
||||
},
|
||||
axios: {
|
||||
get: async (...args) => {
|
||||
calls.axios.push(args);
|
||||
@@ -3171,6 +3212,16 @@ test("admin getrepsblobproxy preserves cross-container signed path and response"
|
||||
loaded.calls.axios[0][0],
|
||||
"http://example.com/api/file/getrepsblob?container=selected-user-container&hash=abc"
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
Cookie: "pedw_admin_session=admin-token"
|
||||
},
|
||||
maxRedirects: 0
|
||||
}
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
||||
loaded.payload
|
||||
@@ -3202,6 +3253,19 @@ test("admin storage refresh uses dedicated POST proxy while ordinary callers rem
|
||||
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
|
||||
});
|
||||
|
||||
test("admin getrepsblobproxy rejects untrusted internal destination before proxy work", async () => {
|
||||
const loaded = loadAdminRepsProxy({
|
||||
BASE_URL: "https://attacker.example"
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default(adminRepsRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assertNoProxyWork(loaded.calls);
|
||||
});
|
||||
|
||||
test("admin storage completion uses dedicated POST route while ordinary completion remains unchanged", () => {
|
||||
const rootDir = path.resolve(__dirname, "..", "..");
|
||||
const adminSource = fs.readFileSync(
|
||||
|
||||
@@ -129,7 +129,7 @@ test("excluded route families remain outside this myportal slice", async () => {
|
||||
const pageSource = read("pages/myportal/index.js");
|
||||
|
||||
assert.match(portalSource, /\/api\/subscriptions\/get-watched-cases/);
|
||||
assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id\)/);
|
||||
assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id,\s*\{/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/getAwaitingSubmissionFromBlob\(thisSession.user.id\)/
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
const assert = require("assert");
|
||||
const {
|
||||
loadModule,
|
||||
createRes,
|
||||
createNextConnectMock,
|
||||
respondSuccessMock,
|
||||
respondErrorMock
|
||||
} = require("../phase21/_shared.cjs");
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push({ name, fn });
|
||||
|
||||
const loadRepresentationList = ({ session = null, administrator = false } = {}) => {
|
||||
const calls = { list: [], download: [] };
|
||||
const mod = loadModule("pages/api/file/getrepsblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => session,
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () =>
|
||||
administrator ? "valid-admin-session" : null,
|
||||
validateAdminSession: () => administrator,
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getRepsBlobs: async (...args) => {
|
||||
calls.list.push(args);
|
||||
return [{ path: "CASE/REP_rep.json" }];
|
||||
},
|
||||
downloadAllRepsFiles: async (...args) => {
|
||||
calls.download.push(args);
|
||||
return { value: [{ id: "rep-1" }] };
|
||||
}
|
||||
});
|
||||
|
||||
return { mod, calls };
|
||||
};
|
||||
|
||||
const representationListRequest = (query = {}) => ({
|
||||
query: {
|
||||
container: "organisation-container",
|
||||
hash: "expected",
|
||||
...query
|
||||
},
|
||||
headers: {}
|
||||
});
|
||||
|
||||
test("representation listing rejects a missing session before Azure access", async () => {
|
||||
const loaded = loadRepresentationList();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(representationListRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assert.strictEqual(loaded.calls.list.length, 0);
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
test("representation listing rejects cross-user container access before Azure access", async () => {
|
||||
const loaded = loadRepresentationList({
|
||||
session: { user: { id: "different-container" } }
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(representationListRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(loaded.calls.list.length, 0);
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
test("representation listing preserves authorised organisation-container access", async () => {
|
||||
const loaded = loadRepresentationList({
|
||||
session: { user: { id: "organisation-container" } }
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(representationListRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(loaded.calls.list, [["organisation-container"]]);
|
||||
assert.strictEqual(loaded.calls.download.length, 1);
|
||||
});
|
||||
|
||||
test("representation listing preserves existing administrative access", async () => {
|
||||
const loaded = loadRepresentationList({ administrator: true });
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(representationListRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.strictEqual(loaded.calls.list.length, 1);
|
||||
});
|
||||
|
||||
test("representation listing rejects a missing identifier before authentication or Azure access", async () => {
|
||||
let sessionCalls = 0;
|
||||
const loaded = loadRepresentationList({
|
||||
session: { user: { id: "organisation-container" } }
|
||||
});
|
||||
loaded.mod = loadModule("pages/api/file/getrepsblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => {
|
||||
sessionCalls += 1;
|
||||
return { user: { id: "organisation-container" } };
|
||||
},
|
||||
authOptions: () => ({}),
|
||||
readAdminSessionCookie: () => null,
|
||||
validateAdminSession: () => false,
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
getRepsBlobs: async () => {
|
||||
loaded.calls.list.push([]);
|
||||
return [];
|
||||
},
|
||||
downloadAllRepsFiles: async () => {
|
||||
loaded.calls.download.push([]);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
representationListRequest({ container: "" }),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
|
||||
assert.strictEqual(sessionCalls, 0);
|
||||
assert.strictEqual(loaded.calls.list.length, 0);
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
let passed = 0;
|
||||
for (const currentTest of tests) {
|
||||
await currentTest.fn();
|
||||
passed += 1;
|
||||
}
|
||||
console.log(
|
||||
`Representation listing ownership tests passed (${passed}/${tests.length}).`
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = run;
|
||||
|
||||
if (require.main === module) {
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,93 @@ test("document/getAwaitingSubmissionFromBlob logs and returns undefined on failu
|
||||
assert.strictEqual(logger.calls.length, 1);
|
||||
});
|
||||
|
||||
test("document/getRepsFromBlob forwards cookies only to trusted origin without redirects", async () => {
|
||||
const axios = createAxiosMock();
|
||||
const logger = createLoggerMock();
|
||||
|
||||
axios.getHandler = async () => ({
|
||||
data: { value: ["representation"] }
|
||||
});
|
||||
|
||||
const document = loadServiceModule("documentDirectService.js", {
|
||||
axios,
|
||||
BASE_URL: "http://example.local",
|
||||
consoleLogger: logger.consoleLogger,
|
||||
hashAPIPath: () => "&hash=abc123"
|
||||
});
|
||||
|
||||
const result = await document.getRepsFromBlob("container-1", {
|
||||
headers: {
|
||||
Cookie: "next-auth.session-token=test-session"
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(normalize(result), {
|
||||
value: ["representation"]
|
||||
});
|
||||
|
||||
assert.strictEqual(axios.calls.length, 1);
|
||||
assert.strictEqual(
|
||||
axios.calls[0].url,
|
||||
"http://example.local/api/file/getrepsblob?container=container-1&hash=abc123"
|
||||
);
|
||||
assert.strictEqual(
|
||||
axios.calls[0].config.headers.Cookie,
|
||||
"next-auth.session-token=test-session"
|
||||
);
|
||||
assert.strictEqual(axios.calls[0].config.maxRedirects, 0);
|
||||
assert.strictEqual(logger.calls.length, 0);
|
||||
});
|
||||
|
||||
test("document/getRepsFromBlob rejects untrusted origin before HTTP request", async () => {
|
||||
const axios = createAxiosMock();
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const document = loadServiceModule("documentDirectService.js", {
|
||||
axios,
|
||||
BASE_URL: "https://untrusted.example",
|
||||
consoleLogger: logger.consoleLogger,
|
||||
hashAPIPath: () => "&hash=abc123"
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
document.getRepsFromBlob("container-1", {
|
||||
headers: {
|
||||
Cookie: "next-auth.session-token=test-session"
|
||||
}
|
||||
}),
|
||||
/Untrusted representation request destination/
|
||||
);
|
||||
|
||||
assert.strictEqual(axios.calls.length, 0);
|
||||
assert.strictEqual(logger.calls.length, 0);
|
||||
});
|
||||
|
||||
test("document/getRepsFromBlob preserves requests without forwarded cookies", async () => {
|
||||
const axios = createAxiosMock();
|
||||
const logger = createLoggerMock();
|
||||
|
||||
axios.getHandler = async () => ({
|
||||
data: { value: ["representation"] }
|
||||
});
|
||||
|
||||
const document = loadServiceModule("documentDirectService.js", {
|
||||
axios,
|
||||
BASE_URL: "http://example.local",
|
||||
consoleLogger: logger.consoleLogger,
|
||||
hashAPIPath: () => "&hash=abc123"
|
||||
});
|
||||
|
||||
const result = await document.getRepsFromBlob("container-1");
|
||||
|
||||
assert.deepStrictEqual(normalize(result), {
|
||||
value: ["representation"]
|
||||
});
|
||||
assert.strictEqual(axios.calls.length, 1);
|
||||
assert.strictEqual(axios.calls[0].config, undefined);
|
||||
assert.strictEqual(logger.calls.length, 0);
|
||||
});
|
||||
|
||||
test("portal/deleteWatchedCases uses signer hash and returns data", async () => {
|
||||
const axios = createAxiosMock();
|
||||
const logger = createLoggerMock();
|
||||
|
||||
@@ -89,8 +89,8 @@ const loadServiceModule = (fileName, injected = {}) => {
|
||||
return injected.axios(config).then((response) => response.data);
|
||||
};
|
||||
|
||||
const defaultGetFileJson = (url) => {
|
||||
return defaultGetJson(url);
|
||||
const defaultGetFileJson = (url, config) => {
|
||||
return defaultGetJson(url, config);
|
||||
};
|
||||
|
||||
const defaultBuildHashedQueryUrl = async (queryUrl) => {
|
||||
@@ -207,8 +207,14 @@ const loadServiceModule = (fileName, injected = {}) => {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
require,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
encodeURIComponent,
|
||||
process: injected.process || {
|
||||
env: {
|
||||
NEXTAUTH_URL: "http://example.local"
|
||||
}
|
||||
},
|
||||
FormData: global.FormData,
|
||||
console: {
|
||||
log: () => {},
|
||||
|
||||
Reference in New Issue
Block a user