DId: segment parsing hardening

This commit is contained in:
shamoon
2026-07-13 07:34:11 -07:00
parent c7153c99dc
commit 423d35261a
2 changed files with 80 additions and 34 deletions
+40 -21
View File
@@ -193,42 +193,61 @@ describe("pages/api/services/proxy", () => {
widgets.linkwarden.mappings.collections.method = originalMethod; widgets.linkwarden.mappings.collections.method = originalMethod;
}); });
it("replaces endpoint segments and rejects unsupported segment keys/values", async () => { it("replaces and encodes endpoint segments", async () => {
getServiceWidget.mockResolvedValue({ type: "segments" }); getServiceWidget.mockResolvedValue({ type: "segments" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const res1 = createMockRes(); const res = createMockRes();
await servicesProxy( await servicesProxy(
{ {
method: "GET", method: "GET",
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "123" }) }, query: {
group: "g",
service: "s",
index: "0",
endpoint: "item",
segments: JSON.stringify({ id: "session:123" }),
}, },
res1, },
res,
); );
expect(res1.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res1.body).toEqual({ endpoint: "items/123" }); expect(res.body).toEqual({ endpoint: "items/session%3A123" });
});
const res2 = createMockRes(); it.each([
await servicesProxy( ["omitted segments", undefined],
{ ["duplicate segment parameters", [JSON.stringify({ id: "123" }), JSON.stringify({ id: "456" })]],
method: "GET", ["unsupported keys", JSON.stringify({ nope: "123" })],
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ nope: "123" }) }, ["missing keys", JSON.stringify({})],
}, ["extra keys", JSON.stringify({ id: "123", extra: "value" })],
res2, ["empty values", JSON.stringify({ id: "" })],
); ["non-string values", JSON.stringify({ id: 123 })],
expect(res2.statusCode).toBe(403); ["literal traversal", JSON.stringify({ id: "../123" })],
expect(res2.body).toEqual({ error: "Unsupported segment" }); ["encoded traversal", JSON.stringify({ id: "%2e%2e%2f123" })],
["double-encoded traversal", JSON.stringify({ id: "%252e%252e%252f123" })],
["encoded slash", JSON.stringify({ id: "session%2f123" })],
["encoded backslash", JSON.stringify({ id: "session%5c123" })],
["malformed encoding", JSON.stringify({ id: "%ZZ" })],
["invalid JSON", "{"],
["arrays", JSON.stringify(["123"])],
["null", "null"],
])("rejects %s in endpoint segments", async (_, segments) => {
getServiceWidget.mockResolvedValue({ type: "segments" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const res3 = createMockRes(); const res = createMockRes();
await servicesProxy( await servicesProxy(
{ {
method: "GET", method: "GET",
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "../123" }) }, query: { group: "g", service: "s", index: "0", endpoint: "item", segments },
}, },
res3, res,
); );
expect(res3.statusCode).toBe(403);
expect(res3.body).toEqual({ error: "Unsupported segment" }); expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unsupported segment" });
expect(handlerFn.handler).not.toHaveBeenCalled();
}); });
it("adds query params based on mapping params + optionalParams", async () => { it("adds query params based on mapping params + optionalParams", async () => {
+39 -12
View File
@@ -7,6 +7,40 @@ import widgets from "widgets/widgets";
const logger = createLogger("servicesProxy"); const logger = createLogger("servicesProxy");
function getSafeSegments(rawSegments, allowedSegments) {
if (typeof rawSegments !== "string" || !Array.isArray(allowedSegments)) return null;
let segments;
try {
segments = JSON.parse(rawSegments);
} catch {
return null;
}
if (!segments || typeof segments !== "object" || Array.isArray(segments)) return null;
const keys = Object.keys(segments);
if (keys.length !== allowedSegments.length || !keys.every((key) => allowedSegments.includes(key))) return null;
const safeSegments = {};
for (const key of allowedSegments) {
const value = segments[key];
if (
typeof value !== "string" ||
value.length === 0 ||
value.includes("%") ||
value.includes("/") ||
value.includes("\\") ||
value.includes("..")
) {
return null;
}
safeSegments[key] = encodeURIComponent(value);
}
return safeSegments;
}
export default async function handler(req, res) { export default async function handler(req, res) {
try { try {
const { service, group, index } = req.query; const { service, group, index } = req.query;
@@ -55,19 +89,12 @@ export default async function handler(req, res) {
if (mapping?.body) req.body = mapping?.body; if (mapping?.body) req.body = mapping?.body;
req.query.endpoint = endpoint; req.query.endpoint = endpoint;
if (req.query.segments) { if (mapping.segments || req.query.segments) {
const segments = JSON.parse(req.query.segments); const segments = getSafeSegments(req.query.segments, mapping.segments);
let validSegments = true; if (!segments) {
Object.keys(segments).forEach((key) => { logger.debug("Unsupported segments");
if (!mapping.segments.includes(key)) { return res.status(403).json({ error: "Unsupported segment" });
logger.debug("Unsupported segment: %s", key);
validSegments = false;
} else if (segments[key].includes("/") || segments[key].includes("\\") || segments[key].includes("..")) {
logger.debug("Unsupported segment value: %s", segments[key]);
validSegments = false;
} }
});
if (!validSegments) return res.status(403).json({ error: "Unsupported segment" });
req.query.endpoint = formatApiCall(endpoint, segments); req.query.endpoint = formatApiCall(endpoint, segments);
} }