fix: complete widget review cleanup
This commit is contained in:
1 parent
56346b2aa8
commit
f003d9ddc9
17 files changed
+279
-145
No files matched your search
@@ -58,6 +58,7 @@ describe("rssFeedRouter", () => {
|
||||
feedIndex: 0,
|
||||
origin: "https://example.com",
|
||||
errorType: "Error",
|
||||
errorMessage: "Failed to fetch [redacted URL]",
|
||||
});
|
||||
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
|
||||
expect(logged).not.toContain("password");
|
||||
|
||||
@@ -86,14 +86,16 @@ describe("timetableRouter source authorization", () => {
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
|
||||
it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
|
||||
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
@@ -128,6 +130,7 @@ describe("timetableRouter source authorization", () => {
|
||||
"http://[::1]:8080",
|
||||
"http://[::ffff:127.0.0.1]:8080",
|
||||
"https://[fd00::1]:8443",
|
||||
"https://192.88.99.1",
|
||||
"http://[64:ff9b::7f00:1]:8080",
|
||||
"http://[2002:7f00:1::]:8080",
|
||||
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
|
||||
@@ -173,17 +176,54 @@ describe("timetableRouter source authorization", () => {
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the exact saved private endpoint when item and board IDs match", async () => {
|
||||
it("rejects the exact saved private endpoint at runtime", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: undefined }),
|
||||
);
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const pinnedAddresses = [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
||||
];
|
||||
mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
|
||||
|
||||
expect(mocks.lookup).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
});
|
||||
|
||||
it("rejects a saved public hostname when it resolves to a private address at request time", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks changing a saved timetable to a private endpoint through item options", async () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ const safeFeedLogContext = (url: string, feedIndex: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/\/\S+/gi, "[redacted URL]");
|
||||
|
||||
const feedsInput = z.object({
|
||||
urls: z.array(z.string()).max(100),
|
||||
maximumAmountPosts: z.number(),
|
||||
@@ -52,6 +54,7 @@ export const rssFeedRouter = createTRPCRouter({
|
||||
logger.warn("RSS feed fetch failed", {
|
||||
...safeFeedLogContext(urls[index] ?? "", index),
|
||||
errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
|
||||
errorMessage: result.reason instanceof Error ? redactFeedFailureMessage(result.reason.message) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ const blockedTimetableSubnets = [
|
||||
["172.16.0.0", 12, "ipv4"],
|
||||
["192.0.0.0", 24, "ipv4"],
|
||||
["192.0.2.0", 24, "ipv4"],
|
||||
["192.88.99.0", 24, "ipv4"],
|
||||
["192.168.0.0", 16, "ipv4"],
|
||||
["198.18.0.0", 15, "ipv4"],
|
||||
["198.51.100.0", 24, "ipv4"],
|
||||
@@ -153,7 +154,7 @@ const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<Ti
|
||||
});
|
||||
}
|
||||
|
||||
return addresses;
|
||||
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
|
||||
};
|
||||
|
||||
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
|
||||
@@ -203,19 +204,19 @@ const resolveTimetableBaseUrlAsync = async (
|
||||
if (allowBoardConfiguration) {
|
||||
if (input.boardId) {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
let savedOptions: string | undefined;
|
||||
if (input.itemId) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)),
|
||||
});
|
||||
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
|
||||
savedOptions = item.options;
|
||||
}
|
||||
const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions);
|
||||
if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
|
||||
if (requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
|
||||
return { baseUrl: requestedBaseUrl };
|
||||
}
|
||||
|
||||
// Resolve immediately before every custom request. A URL that was public when saved can later resolve to a
|
||||
// private address, so matching the persisted value is not sufficient protection against DNS rebinding.
|
||||
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
|
||||
return { baseUrl: requestedBaseUrl, pinnedAddresses };
|
||||
}
|
||||
@@ -241,7 +242,10 @@ const resolveTimetableBaseUrlAsync = async (
|
||||
if (requestedBaseUrl !== savedBaseUrl) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
|
||||
}
|
||||
return { baseUrl: savedBaseUrl };
|
||||
if (savedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return { baseUrl: savedBaseUrl };
|
||||
|
||||
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(savedBaseUrl);
|
||||
return { baseUrl: savedBaseUrl, pinnedAddresses };
|
||||
};
|
||||
|
||||
export const timetableRouter = createTRPCRouter({
|
||||
@@ -253,9 +257,11 @@ export const timetableRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { baseUrl } = await resolveTimetableBaseUrlAsync(ctx, input);
|
||||
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input);
|
||||
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
|
||||
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
|
||||
const { data } = await timetableGetTimetableRequestHandler
|
||||
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
|
||||
.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
searchStations: publicProcedure
|
||||
|
||||
Reference in new issue
Block a user