diff --git a/app/components/assets/assets-index/advanced-filters/operator-selector.tsx b/app/components/assets/assets-index/advanced-filters/operator-selector.tsx index 25cc9b300..9b9abcf7f 100644 --- a/app/components/assets/assets-index/advanced-filters/operator-selector.tsx +++ b/app/components/assets/assets-index/advanced-filters/operator-selector.tsx @@ -25,7 +25,7 @@ function FilterOperatorDisplay({ } /** Maps the FilterOperator to a user friendly name */ -const operatorsMap: Record = { +export const operatorsMap: Record = { is: ["=", "is"], isNot: ["≠", "Is not"], contains: ["∋", "Contains"], diff --git a/app/modules/asset/data.server.ts b/app/modules/asset/data.server.ts index 7eae6a275..06f34785c 100644 --- a/app/modules/asset/data.server.ts +++ b/app/modules/asset/data.server.ts @@ -219,7 +219,7 @@ export async function advancedModeLoader({ filters, serializedCookie: filtersCookie, redirectNeeded, - } = await getAdvancedFiltersFromRequest(request, organizationId); + } = await getAdvancedFiltersFromRequest(request, organizationId, settings); const currentFilterParams = new URLSearchParams(filters || ""); const searchParams = filters @@ -227,9 +227,12 @@ export async function advancedModeLoader({ : getCurrentSearchParams(request); const paramsValues = getParamsValues(searchParams); const { teamMemberIds } = paramsValues; - if (filters && redirectNeeded) { + + if (redirectNeeded) { const cookieParams = new URLSearchParams(filters); - return redirect(`/assets?${cookieParams.toString()}`); + return redirect(`/assets?${cookieParams.toString()}`, { + headers: filtersCookie ? [setCookie(filtersCookie)] : undefined, + }); } /** Query tierLimit, assets & Asset index settings */ diff --git a/app/modules/asset/utils.server.ts b/app/modules/asset/utils.server.ts index bbf88a6a1..0519c1195 100644 --- a/app/modules/asset/utils.server.ts +++ b/app/modules/asset/utils.server.ts @@ -1,6 +1,8 @@ import type { Asset, AssetStatus, Location, Prisma } from "@prisma/client"; import { z } from "zod"; +import { filterOperatorSchema } from "~/components/assets/assets-index/advanced-filters/schema"; import { getParamsValues } from "~/utils/list"; +import type { Column } from "../asset-index-settings/helpers"; export function getLocationUpdateNoteContent({ currentLocation, @@ -144,3 +146,62 @@ export function getAssetsWhereInput({ return where; } + +/** + * Schema for validating advanced filter parameter format + * Validates the 'operator:value' format and ensures operator is valid + */ +export const advancedFilterFormatSchema = z.string().refine( + (value) => { + const parts = value.split(":"); + if (parts.length !== 2) return false; + + const [operator] = parts; + return filterOperatorSchema.safeParse(operator).success; + }, + { + message: "Filter must be in format 'operator:value' with valid operator", + } +); + +/** + * Validates if a filter value matches the expected advanced filter format + * Uses Zod schema for strict type validation + * @param value - The filter value to validate + * @returns boolean indicating if the value matches advanced filter format + */ +function isValidAdvancedFilterFormat(value: string): boolean { + return advancedFilterFormatSchema.safeParse(value).success; +} + +/** + * Validates and sanitizes URL parameters for advanced index mode + * Removes any parameters that don't match the expected advanced filter format + * @param searchParams - The URL search parameters to validate + * @param columns - The configured columns for advanced index + * @returns Validated and sanitized search parameters + */ +export function validateAdvancedFilterParams( + searchParams: URLSearchParams, + columns: Column[] +): URLSearchParams { + const validatedParams = new URLSearchParams(); + const columnNames = columns.map((col) => col.name); + + // Iterate through all parameters + searchParams.forEach((value, key) => { + // Preserve non-filter params (pagination, sorting, etc) + if (!columnNames.includes(key as any)) { + validatedParams.append(key, value); + return; + } + + // Validate filter format for column parameters + if (isValidAdvancedFilterFormat(value)) { + validatedParams.append(key, value); + } + // Invalid format - parameter will be dropped + }); + + return validatedParams; +} diff --git a/app/utils/cookies.server.ts b/app/utils/cookies.server.ts index 5d83260c5..eca9301ea 100644 --- a/app/utils/cookies.server.ts +++ b/app/utils/cookies.server.ts @@ -1,7 +1,10 @@ +import type { AssetIndexSettings } from "@prisma/client"; import { createCookie } from "@remix-run/node"; // or cloudflare/deno import type { Cookie } from "@remix-run/node"; import { cleanParamsForCookie } from "~/hooks/search-params"; +import { advancedFilterFormatSchema } from "~/modules/asset/utils.server"; +import type { Column } from "~/modules/asset-index-settings/helpers"; import { getCurrentSearchParams } from "./http.server"; // find cookie by name from request headers @@ -141,36 +144,87 @@ export const createAdvancedAssetFilterCookie = (orgId: string) => maxAge: 60 * 60 * 24 * 365, // 1 year }); +/** + * Gets and validates advanced filters from request parameters + * Ensures URL parameters match the expected advanced filter format + * @param request - The incoming request + * @param organizationId - The organization ID for the request + * @param settings - The asset index settings containing column configuration + * @returns Object containing filters, serialized cookie, and redirect status + */ export async function getAdvancedFiltersFromRequest( request: Request, - organizationId: string -) { + organizationId: string, + settings: AssetIndexSettings +): Promise<{ + filters: string | undefined; + serializedCookie: string | undefined; + redirectNeeded: boolean; +}> { let filters = getCurrentSearchParams(request).toString(); const cookieHeader = request.headers.get("Cookie"); + const advancedAssetFilterCookie = + createAdvancedAssetFilterCookie(organizationId); - const assetFilterCookie = createAdvancedAssetFilterCookie(organizationId); if (filters) { - // Clean filters before storing in cookie - const cleanedFilters = cleanParamsForCookie(filters); - // Only serialize to cookie if we have filters after cleaning - const serializedCookie = cleanedFilters - ? await assetFilterCookie.serialize(cleanedFilters) - : null; + const validatedParams = new URLSearchParams(); + const columnNames = (settings.columns as Column[]).map((col) => col.name); - // Return original filters for URL but cleaned cookie - return { filters, serializedCookie }; - } else if (cookieHeader) { - // Use existing cookie filter but clean it - filters = (await assetFilterCookie.parse(cookieHeader)) || {}; - const cleanedFilters = cleanParamsForCookie(filters); + new URLSearchParams(filters).forEach((value, key) => { + if (!columnNames.includes(key as any)) { + validatedParams.append(key, value); + return; + } + + if (advancedFilterFormatSchema.safeParse(value).success) { + validatedParams.append(key, value); + } + }); + + const validatedParamsString = validatedParams.toString(); + const cleanedFilters = cleanParamsForCookie(validatedParamsString); - // Only redirect if we have filters after cleaning return { - filters: cleanedFilters, - redirectNeeded: !!cleanedFilters, + filters: validatedParamsString, + serializedCookie: cleanedFilters + ? await advancedAssetFilterCookie.serialize(cleanedFilters) + : undefined, + redirectNeeded: validatedParamsString !== filters, }; + } else if (cookieHeader) { + filters = (await advancedAssetFilterCookie.parse(cookieHeader)) || ""; + + if (filters) { + const validatedParams = new URLSearchParams(); + const columnNames = (settings.columns as Column[]).map((col) => col.name); + + new URLSearchParams(filters).forEach((value, key) => { + if (!columnNames.includes(key as any)) { + validatedParams.append(key, value); + return; + } + + if (advancedFilterFormatSchema.safeParse(value).success) { + validatedParams.append(key, value); + } + }); + + const validatedParamsString = validatedParams.toString(); + const cleanedFilters = cleanParamsForCookie(validatedParamsString); + + return { + filters: cleanedFilters || undefined, + serializedCookie: undefined, + redirectNeeded: !!cleanedFilters, + }; + } } - return { filters }; + + return { + filters: "", + serializedCookie: undefined, + redirectNeeded: false, + }; } /** HIDE PWA INSTALL PROMPT COOKIE */ diff --git a/app/utils/csv.server.ts b/app/utils/csv.server.ts index b44477ce7..082acba42 100644 --- a/app/utils/csv.server.ts +++ b/app/utils/csv.server.ts @@ -250,7 +250,8 @@ export async function exportAssetsFromIndexToCsv({ /** Parse filters */ const { filters } = await getAdvancedFiltersFromRequest( request, - organizationId + organizationId, + settings ); /** Make an array of the ids and check if we have to take all */