From 08fe81de129a4283387b8a23a3b8c12c9c76c360 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 25 Oct 2026 18:41:51 +0700 Subject: [PATCH 1/5] fix(web): harden ListSidebarOne, RangeSlider, and LocaleSwitcher (#1052) Closes #1052 --- .../[locale]/components/list-sidebar-one.tsx | 176 +++++++++++++----- .../app/[locale]/components/range-slider.tsx | 70 ++++++- .../components/elementary/LocaleSwitcher.tsx | 46 ++++- .../__tests__/core-rate-architecture.test.ts | 64 +++++++ 4 files changed, 291 insertions(+), 65 deletions(-) diff --git a/apps/web/src/app/[locale]/components/list-sidebar-one.tsx b/apps/web/src/app/[locale]/components/list-sidebar-one.tsx index c6efc37e..994435f7 100644 --- a/apps/web/src/app/[locale]/components/list-sidebar-one.tsx +++ b/apps/web/src/app/[locale]/components/list-sidebar-one.tsx @@ -23,6 +23,88 @@ export interface ListSidebarOneProps { brandFacets?: { name: string; count: number }[]; } +export function sanitizeSidebarRating(value: unknown): number { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num) || num <= 0) return 0; + return Number(Math.min(5, num).toFixed(1)); +} + +export function sanitizeSidebarPositiveInts( + csvOrValue: unknown, + maxItemValue = 65_536 +): number[] { + if (typeof csvOrValue !== 'string' || !csvOrValue.trim()) return []; + const seen = new Set(); + const out: number[] = []; + for (const part of csvOrValue.split(',')) { + const cleaned = part.replace(/\p{Cc}+/gu, '').trim(); + if (!cleaned) continue; + const num = Number(cleaned); + if (!Number.isFinite(num) || num <= 0) continue; + const rounded = Math.round(num); + if (rounded <= 0 || rounded > maxItemValue || seen.has(rounded)) continue; + seen.add(rounded); + out.push(rounded); + if (out.length >= 20) break; + } + return out; +} + +export function sanitizeSidebarBrands(csvOrValue: unknown): string[] { + if (typeof csvOrValue !== 'string' || !csvOrValue.trim()) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of csvOrValue.split(',')) { + const cleaned = part + .replace(/\p{Cc}+/gu, '') + .trim() + .slice(0, 64); + if (!cleaned) continue; + const lower = cleaned.toLowerCase(); + if (seen.has(lower)) continue; + seen.add(lower); + out.push(cleaned); + if (out.length >= 25) break; + } + return out; +} + +export function sanitizeSidebarPriceMillion( + rawPriceVnd: unknown, + sliderMin: number, + sliderMax: number, + fallbackMillion: number +): number { + const safeMin = Number.isFinite(sliderMin) && sliderMin >= 0 ? sliderMin : 0; + const safeMax = + Number.isFinite(sliderMax) && sliderMax >= safeMin ? sliderMax : safeMin; + const safeFallback = Math.min( + safeMax, + Math.max( + safeMin, + Number.isFinite(fallbackMillion) ? fallbackMillion : safeMin + ) + ); + if (rawPriceVnd === null || rawPriceVnd === undefined || rawPriceVnd === '') { + return safeFallback; + } + const num = + typeof rawPriceVnd === 'number' ? rawPriceVnd : Number(rawPriceVnd); + if (!Number.isFinite(num) || num <= 0) return safeFallback; + const million = Math.round(num / 1_000_000); + if (!Number.isFinite(million) || million <= 0) return safeFallback; + return Math.min(safeMax, Math.max(safeMin, million)); +} + +export function sanitizeSidebarKeyword(value: unknown): string { + if (typeof value !== 'string') return ''; + return value + .replace(/\p{Cc}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120); +} + export default function ListSidebarOne({ categorySlug = 'smartphones', categories = [], @@ -52,43 +134,32 @@ export default function ListSidebarOne({ ); // Parse active filter state from URL query params - const currentQ = searchParams?.get('q') || ''; - const currentMinRating = Number(searchParams?.get('rating') || 0); - const currentBrands = (searchParams?.get('brands') || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const currentRams = (searchParams?.get('ram') || '') - .split(',') - .map((s) => Number(s.trim())) - .filter((n) => !Number.isNaN(n) && n > 0); - const currentStorages = (searchParams?.get('storage') || '') - .split(',') - .map((s) => Number(s.trim())) - .filter((n) => !Number.isNaN(n) && n > 0); + const currentQ = sanitizeSidebarKeyword(searchParams?.get('q')); + const currentMinRating = sanitizeSidebarRating(searchParams?.get('rating')); + const currentBrands = sanitizeSidebarBrands(searchParams?.get('brands')); + const currentRams = sanitizeSidebarPositiveInts( + searchParams?.get('ram'), + 1024 + ); + const currentStorages = sanitizeSidebarPositiveInts( + searchParams?.get('storage'), + 16_384 + ); const sliderMin = schema.priceRangeMillion.min; const sliderMax = schema.priceRangeMillion.max; - const rawMinMillion = searchParams?.get('minPrice') - ? Math.min( - sliderMax, - Math.max( - sliderMin, - Math.round(Number(searchParams.get('minPrice')) / 1_000_000) || - sliderMin - ) - ) - : sliderMin; - const rawMaxMillion = searchParams?.get('maxPrice') - ? Math.max( - sliderMin, - Math.min( - sliderMax, - Math.round(Number(searchParams.get('maxPrice')) / 1_000_000) || - sliderMax - ) - ) - : sliderMax; + const rawMinMillion = sanitizeSidebarPriceMillion( + searchParams?.get('minPrice'), + sliderMin, + sliderMax, + sliderMin + ); + const rawMaxMillion = sanitizeSidebarPriceMillion( + searchParams?.get('maxPrice'), + sliderMin, + sliderMax, + sliderMax + ); const urlMinMillion = Math.min(rawMinMillion, rawMaxMillion); const urlMaxMillion = Math.max(rawMinMillion, rawMaxMillion); @@ -118,16 +189,18 @@ export default function ListSidebarOne({ }; const toggleMultiString = (paramKey: string, val: string) => { + const cleanVal = val + .replace(/\p{Cc}+/gu, '') + .trim() + .slice(0, 64); + if (!cleanVal) return; updateUrlParams((params) => { - const existing = (params.get(paramKey) || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const lower = val.toLowerCase(); + const existing = sanitizeSidebarBrands(params.get(paramKey)); + const lower = cleanVal.toLowerCase(); const has = existing.some((x) => x.toLowerCase() === lower); const next = has ? existing.filter((x) => x.toLowerCase() !== lower) - : [...existing, val]; + : [...existing, cleanVal]; if (next.length > 0) { params.set(paramKey, next.join(',')); } else { @@ -137,13 +210,14 @@ export default function ListSidebarOne({ }; const toggleMultiNumber = (paramKey: string, val: number) => { + if (!Number.isFinite(val) || val <= 0) return; + const cleanVal = Math.round(val); updateUrlParams((params) => { - const existing = (params.get(paramKey) || '') - .split(',') - .map((s) => Number(s.trim())) - .filter((n) => !Number.isNaN(n) && n > 0); - const has = existing.includes(val); - const next = has ? existing.filter((x) => x !== val) : [...existing, val]; + const existing = sanitizeSidebarPositiveInts(params.get(paramKey)); + const has = existing.includes(cleanVal); + const next = has + ? existing.filter((x) => x !== cleanVal) + : [...existing, cleanVal]; if (next.length > 0) { params.set(paramKey, next.join(',')); } else { @@ -153,9 +227,10 @@ export default function ListSidebarOne({ }; const handleRatingChange = (ratingVal: number) => { + const safeRating = sanitizeSidebarRating(ratingVal); updateUrlParams((params) => { - if (ratingVal > 0) { - params.set('rating', String(ratingVal)); + if (safeRating > 0) { + params.set('rating', String(safeRating)); } else { params.delete('rating'); } @@ -218,8 +293,9 @@ export default function ListSidebarOne({ const handleApplyForm = (e: React.FormEvent) => { e.preventDefault(); updateUrlParams((params) => { - if (keyword.trim()) { - params.set('q', keyword.trim()); + const cleanQ = sanitizeSidebarKeyword(keyword); + if (cleanQ) { + params.set('q', cleanQ); } else { params.delete('q'); } diff --git a/apps/web/src/app/[locale]/components/range-slider.tsx b/apps/web/src/app/[locale]/components/range-slider.tsx index 8ee23dc5..351ef1af 100644 --- a/apps/web/src/app/[locale]/components/range-slider.tsx +++ b/apps/web/src/app/[locale]/components/range-slider.tsx @@ -15,6 +15,41 @@ export interface RangeSliderProps { onChangeComplete?: (values: [number, number]) => void; } +export function sanitizeSliderBounds( + min?: unknown, + max?: unknown, + step?: unknown +): { min: number; max: number; step: number } { + const rawMin = typeof min === 'number' ? min : Number(min); + const rawMax = typeof max === 'number' ? max : Number(max); + const rawStep = typeof step === 'number' ? step : Number(step); + const safeMin = Number.isFinite(rawMin) ? rawMin : 0; + const safeMax = + Number.isFinite(rawMax) && rawMax >= safeMin ? rawMax : safeMin + 100; + const safeStep = Number.isFinite(rawStep) && rawStep > 0 ? rawStep : 1; + return { min: safeMin, max: safeMax, step: safeStep }; +} + +export function sanitizeSliderTuple( + tuple: unknown, + min: number, + max: number +): [number, number] { + const { min: safeMin, max: safeMax } = sanitizeSliderBounds(min, max, 1); + if (!Array.isArray(tuple) || tuple.length < 2) { + return [safeMin, safeMax]; + } + const n0 = typeof tuple[0] === 'number' ? tuple[0] : Number(tuple[0]); + const n1 = typeof tuple[1] === 'number' ? tuple[1] : Number(tuple[1]); + const clamped0 = Number.isFinite(n0) + ? Math.min(safeMax, Math.max(safeMin, n0)) + : safeMin; + const clamped1 = Number.isFinite(n1) + ? Math.min(safeMax, Math.max(safeMin, n1)) + : safeMax; + return [Math.min(clamped0, clamped1), Math.max(clamped0, clamped1)]; +} + export default function RangeSlider({ min = 0, max = 100, @@ -26,8 +61,13 @@ export default function RangeSlider({ onChangeComplete, }: RangeSliderProps) { const tSidebar = useTranslations('catalog.sidebar'); - const [internalRange, setInternalRange] = useState<[number, number]>( - value ?? defaultValue + const { + min: safeMin, + max: safeMax, + step: safeStep, + } = sanitizeSliderBounds(min, max, step); + const [internalRange, setInternalRange] = useState<[number, number]>(() => + sanitizeSliderTuple(value ?? defaultValue, safeMin, safeMax) ); // Depend on the two numbers, not the array, so a new array with the same @@ -36,11 +76,15 @@ export default function RangeSlider({ const valueMax = value?.[1]; useEffect(() => { if (valueMin !== undefined && valueMax !== undefined) { - setInternalRange([valueMin, valueMax]); + setInternalRange( + sanitizeSliderTuple([valueMin, valueMax], safeMin, safeMax) + ); } - }, [valueMin, valueMax]); + }, [valueMin, valueMax, safeMin, safeMax]); - const activeRange = value ?? internalRange; + const activeRange = value + ? sanitizeSliderTuple(value, safeMin, safeMax) + : internalRange; const handleRangeChange = (values: number | number[]) => { if ( @@ -48,7 +92,11 @@ export default function RangeSlider({ typeof values[0] === 'number' && typeof values[1] === 'number' ) { - const next: [number, number] = [values[0], values[1]]; + const next = sanitizeSliderTuple( + [values[0], values[1]], + safeMin, + safeMax + ); setInternalRange(next); onChange?.(next); } @@ -60,7 +108,9 @@ export default function RangeSlider({ typeof values[0] === 'number' && typeof values[1] === 'number' ) { - onChangeComplete?.([values[0], values[1]]); + onChangeComplete?.( + sanitizeSliderTuple([values[0], values[1]], safeMin, safeMax) + ); } }; @@ -68,9 +118,9 @@ export default function RangeSlider({
(routing.locales); + +export function sanitizeSwitcherLocale( + value: unknown, + fallback: AppLocale = 'vi' +): AppLocale { + if (typeof value !== 'string') return fallback; + const cleaned = value + .replace(/\p{Cc}+/gu, '') + .trim() + .toLowerCase(); + if (SUPPORTED_SWITCHER_LOCALES.has(cleaned)) { + return cleaned as AppLocale; + } + return fallback; +} + +export function sanitizeSwitcherQueryString(rawSearch: unknown): string { + if (typeof rawSearch !== 'string') return ''; + const cleaned = rawSearch + .replace(/\p{Cc}+/gu, '') + .trim() + .replace(/^\?+/, '') + .slice(0, 1024); + if (!cleaned) return ''; + try { + return new URLSearchParams(cleaned).toString(); + } catch { + return ''; + } +} + export function LocaleSwitcher({ locale: propLocale, className, @@ -105,7 +137,10 @@ export function LocaleSwitcher({ }: LocaleSwitcherProps) { const contextLocale = useLocale(); const tNav = useTranslations('rate.nav'); - const currentLocale = (propLocale || contextLocale) as AppLocale; + const currentLocale = sanitizeSwitcherLocale( + propLocale || contextLocale, + sanitizeSwitcherLocale(contextLocale, 'vi') + ); const [isOpen, setIsOpen] = useState(false); const [, startTransition] = useTransition(); const dropdownRef = useRef(null); @@ -128,20 +163,21 @@ export function LocaleSwitcher({ const handleSelect = (nextLocale: string) => { setIsOpen(false); - if (nextLocale === currentLocale) return; + const safeNextLocale = sanitizeSwitcherLocale(nextLocale, currentLocale); + if (safeNextLocale === currentLocale) return; if (typeof document !== 'undefined') { - document.cookie = `NEXT_LOCALE=${nextLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; + document.cookie = `NEXT_LOCALE=${safeNextLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; } // Read at click time: useSearchParams would make the switcher // client-only on cached pages, so it popped in after hydration and // shifted the navbar (DOS/Rate#515). - const queryParams = window.location.search.slice(1); + const queryParams = sanitizeSwitcherQueryString(window.location.search); startTransition(() => { router.replace( queryParams.length > 0 ? `${pathname}?${queryParams}` : pathname, - { locale: nextLocale as AppLocale } + { locale: safeNextLocale } ); router.refresh(); }); diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index bae67f38..5cfa3957 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -12788,6 +12788,70 @@ async function runCoreArchitectureSuite() { ); } + // ------------------------------------------------------------------------- + // 126g. ListSidebarOne, RangeSlider & LocaleSwitcher Bounds (#1052) + // ------------------------------------------------------------------------- + { + const { + sanitizeSidebarRating, + sanitizeSidebarPositiveInts, + sanitizeSidebarBrands, + sanitizeSidebarPriceMillion, + sanitizeSidebarKeyword, + } = await import('../../../app/[locale]/components/list-sidebar-one'); + const { sanitizeSliderBounds, sanitizeSliderTuple } = + await import('../../../app/[locale]/components/range-slider'); + const { sanitizeSwitcherLocale, sanitizeSwitcherQueryString } = + await import('../../../components/elementary/LocaleSwitcher'); + + assert.equal(sanitizeSidebarRating('4.5'), 4.5); + assert.equal(sanitizeSidebarRating('9.9'), 5); + assert.equal(sanitizeSidebarRating('-2'), 0); + assert.equal(sanitizeSidebarRating('NaN'), 0); + assert.deepEqual( + sanitizeSidebarPositiveInts(' 8, 12, 8, -4, Infinity, 999999 ', 1024), + [8, 12] + ); + assert.deepEqual( + sanitizeSidebarBrands(' Apple\u0000 , Samsung , apple , '), + ['Apple', 'Samsung'] + ); + assert.equal(sanitizeSidebarPriceMillion('15000000', 1, 80, 1), 15); + assert.equal(sanitizeSidebarPriceMillion('Infinity', 1, 80, 1), 1); + assert.equal(sanitizeSidebarPriceMillion('-5000000', 1, 80, 80), 80); + assert.equal( + sanitizeSidebarKeyword(' iPhone\r\n16\u0000 Pro '), + 'iPhone 16 Pro' + ); + + assert.deepEqual(sanitizeSliderBounds(5, 60, 2), { + min: 5, + max: 60, + step: 2, + }); + assert.deepEqual(sanitizeSliderBounds(50, 10, -1), { + min: 50, + max: 150, + step: 1, + }); + assert.deepEqual(sanitizeSliderTuple([45, 15], 10, 60), [15, 45]); + assert.deepEqual(sanitizeSliderTuple([-10, 999], 10, 60), [10, 60]); + assert.deepEqual(sanitizeSliderTuple(null, 10, 60), [10, 60]); + + assert.equal(sanitizeSwitcherLocale(' EN\u0000 '), 'en'); + assert.equal(sanitizeSwitcherLocale('fr', 'vi'), 'vi'); + assert.equal(sanitizeSwitcherLocale(null, 'cs'), 'cs'); + assert.equal( + sanitizeSwitcherQueryString('?brand=Apple\u0000&sort=price_asc'), + 'brand=Apple&sort=price_asc' + ); + assert.equal(sanitizeSwitcherQueryString(' '), ''); + + console.log( + ' ✅ [126g/134] ListSidebarOne, RangeSlider & LocaleSwitcher Bounds (#1052) passed' + ); + } + // ------------------------------------------------------------------------- // 127. Rate Server Index & Extension Entity, Slug, and Category Bounds (#1029) // ------------------------------------------------------------------------- From b53112fbb0a0fc4e851432c71ba69ae32378de2c Mon Sep 17 00:00:00 2001 From: JOY Date: Mon, 26 Oct 2026 09:13:14 +0700 Subject: [PATCH 2/5] fix(web): harden offers explorer, share audit and value calculator (WIP, #1055) Work in progress from the Antigravity "Rate" (web) agent, committed for handoff after it ran out of quota. Covers ProductOffersExplorer, ProductShareAuditButton and ProductValueCalculator. Its last run of typecheck, lint and unit tests for #1055 finished, but the result was not recorded, and no independent review has run yet. Co-Authored-By: Claude Opus 5.5 --- .../leaderboard/ProductOffersExplorer.tsx | 94 ++++++++++++++----- .../leaderboard/ProductShareAuditButton.tsx | 58 +++++++++--- .../leaderboard/ProductValueCalculator.tsx | 30 ++++-- .../__tests__/core-rate-architecture.test.ts | 67 +++++++++++++ 4 files changed, 202 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx index eceb61e6..4c1fc889 100644 --- a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx +++ b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx @@ -46,16 +46,55 @@ export function computeMerchantTrustScore(riskScore: unknown): number { return 100 - clampedRisk; } +export function sanitizeOfferRiskScore100(value: unknown): number { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num) || num <= 0) return 0; + return Math.min(100, Math.round(num)); +} + export function isSafeExternalOfferUrl(url: unknown): boolean { - if (typeof url !== 'string' || !url.trim()) return false; + if (typeof url !== 'string') return false; + if (/\p{Cc}/u.test(url)) return false; + const trimmed = url.trim(); + if (!trimmed || trimmed.length > 2048 || !/^https?:\/\//i.test(trimmed)) { + return false; + } try { - const parsed = new URL(url.trim()); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + if (parsed.username || parsed.password) return false; + return true; } catch { return false; } } +export function sanitizeOffersMarkdownCell(value: unknown): string { + if (typeof value !== 'string') return '-'; + const cleaned = value + .replace(/\p{Cc}+/gu, ' ') + .replace(/\|/g, '-') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); + return cleaned || '-'; +} + +export function sanitizeOffersFileToken(value: unknown): string { + if (typeof value !== 'string') return 'audit'; + const cleaned = value + .replace(/\p{Cc}+/gu, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120); + return cleaned || 'audit'; +} + export function computeOffersPriceSpreadAudit( offers: readonly MerchantOffer[], implausibleOfferIds: ReadonlySet = new Set() @@ -187,11 +226,7 @@ export default function ProductOffersExplorer({ implausibleOfferIds, ]); - const safeSlugFileToken = - (typeof productSlug === 'string' ? productSlug : '') - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'audit'; + const safeSlugFileToken = sanitizeOffersFileToken(productSlug); const handleCopyOffersMarkdown = async () => { if (typeof navigator === 'undefined' || filteredOffers.length === 0) return; @@ -213,18 +248,19 @@ export default function ProductOffersExplorer({ Number.isFinite(Number(o.price_original)) && Number(o.price_original) > o.price_current && Number(o.price_original) <= MAX_VALID_VND_PRICE; + const verdictCell = implausibleOfferIds.has(o.id) + ? t('offersImplausiblyLowBadge') + : analysis + ? discountVerdictCopy(tVerdict, analysis).label + : tVerdict('labelNormal'); return [ - o.platform.toUpperCase(), - o.merchant.name.replace(/\|/g, '-'), + sanitizeOffersMarkdownCell(o.platform.toUpperCase()), + sanitizeOffersMarkdownCell(o.merchant.name), o.merchant.is_official ? 'Yes' : 'No', `${trust}%`, `${o.price_current.toLocaleString('vi-VN')} ₫`, hasValidOrig ? `${o.price_original!.toLocaleString('vi-VN')} ₫` : '-', - implausibleOfferIds.has(o.id) - ? t('offersImplausiblyLowBadge') - : analysis - ? discountVerdictCopy(tVerdict, analysis).label - : tVerdict('labelNormal'), + sanitizeOffersMarkdownCell(verdictCell), ]; }); @@ -236,14 +272,20 @@ export default function ProductOffersExplorer({ count: priceSpreadAudit.platformCount, spreadVnd: priceSpreadAudit.spreadVnd.toLocaleString('vi-VN'), spreadPct: priceSpreadAudit.spreadPct, - cheapestPlatform: priceSpreadAudit.cheapest.platform.toUpperCase(), - cheapestMerchant: priceSpreadAudit.cheapest.merchant.name, - priciestPlatform: priceSpreadAudit.priciest.platform.toUpperCase(), + cheapestPlatform: sanitizeOffersMarkdownCell( + priceSpreadAudit.cheapest.platform.toUpperCase() + ), + cheapestMerchant: sanitizeOffersMarkdownCell( + priceSpreadAudit.cheapest.merchant.name + ), + priciestPlatform: sanitizeOffersMarkdownCell( + priceSpreadAudit.priciest.platform.toUpperCase() + ), }) : ''; const md = [ - `### 🛡️ Rate.vn cross-store price check: ${productName}`, + `### 🛡️ Rate.vn cross-store price check: ${sanitizeOffersMarkdownCell(productName)}`, radarLine + spreadLine, `| ${headers.join(' | ')} |`, `| ${sep.join(' | ')} |`, @@ -290,8 +332,8 @@ export default function ProductOffersExplorer({ o.merchant.name, o.merchant.is_official ? 'true' : 'false', String(trust), - String(o.merchant.dosafe_risk_score || 0), - String(o.merchant.review_risk_score || 0), + String(sanitizeOfferRiskScore100(o.merchant.dosafe_risk_score)), + String(sanitizeOfferRiskScore100(o.merchant.review_risk_score)), String(o.price_current), hasValidOrig ? String(o.price_original) : '', implausibleOfferIds.has(o.id) @@ -682,8 +724,14 @@ export default function ProductOffersExplorer({ className="text-muted" style={{ fontSize: '0.8125rem' }} > - DOSafe: {offer.merchant.dosafe_risk_score}/100 | - Review Risk: {offer.merchant.review_risk_score} + DOSafe:{' '} + {sanitizeOfferRiskScore100( + offer.merchant.dosafe_risk_score + )} + /100 | Review Risk:{' '} + {sanitizeOfferRiskScore100( + offer.merchant.review_risk_score + )} /100
diff --git a/apps/web/src/components/leaderboard/ProductShareAuditButton.tsx b/apps/web/src/components/leaderboard/ProductShareAuditButton.tsx index 2e892648..c5815069 100644 --- a/apps/web/src/components/leaderboard/ProductShareAuditButton.tsx +++ b/apps/web/src/components/leaderboard/ProductShareAuditButton.tsx @@ -26,6 +26,19 @@ interface ProductShareAuditButtonProps { readonly radarSignal?: RadarSignal; } +export function sanitizeShareAuditText( + value: unknown, + maxLength = 200 +): string { + if (typeof value !== 'string') return ''; + return value + .replace(/\p{Cc}+/gu, ' ') + .replace(/\|/g, '-') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); +} + export function normalizeShareAuditFields(input: { readonly modelKey: string; readonly userScore5: number; @@ -45,7 +58,9 @@ export function normalizeShareAuditFields(input: { : null; const rawReviews = Number(input.authenticReviews); const safeAuthenticReviews = - Number.isFinite(rawReviews) && rawReviews > 0 ? Math.floor(rawReviews) : 0; + Number.isFinite(rawReviews) && rawReviews > 0 + ? Math.min(10_000_000, Math.floor(rawReviews)) + : 0; const rawPrice = Number(input.minPriceVnd); const safeMinPriceVnd = Number.isFinite(rawPrice) && rawPrice > 0 && rawPrice <= MAX_VALID_VND_PRICE @@ -53,12 +68,18 @@ export function normalizeShareAuditFields(input: { : 0; const rawOffers = Number(input.offerCount); const safeOfferCount = - Number.isFinite(rawOffers) && rawOffers > 0 ? Math.floor(rawOffers) : 0; + Number.isFinite(rawOffers) && rawOffers > 0 + ? Math.min(10_000_000, Math.floor(rawOffers)) + : 0; const safeModelKeyToken = (typeof input.modelKey === 'string' ? input.modelKey : '') + .replace(/\p{Cc}+/gu, '') + .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'audit'; + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'audit'; return { safeUserScore5, @@ -102,6 +123,13 @@ export default function ProductShareAuditButton({ offerCount, }); + const safeName = sanitizeShareAuditText(name, 160); + const safeBrand = sanitizeShareAuditText(brand, 80); + const safeModelKey = sanitizeShareAuditText(modelKey, 120); + const safeKeySpecLine = keySpecLine + ? sanitizeShareAuditText(keySpecLine, 240) + : ''; + const handleCopyMarkdown = async () => { if (typeof window === 'undefined') return; try { @@ -124,17 +152,17 @@ export default function ProductShareAuditButton({ ? `${safeExpertScore10.toFixed(1)}/10` : t('shareAuditMdNoExpert'); const lines = [ - t('shareAuditMdTitle', { name, brand }), + t('shareAuditMdTitle', { name: safeName, brand: safeBrand }), t('shareAuditMdUserScore', { score: userScoreStr }), t('shareAuditMdExpertScore', { score: expertScoreStr }), - ...(keySpecLine - ? [t('shareAuditMdKeySpecs', { specs: keySpecLine })] + ...(safeKeySpecLine + ? [t('shareAuditMdKeySpecs', { specs: safeKeySpecLine })] : []), t('shareAuditMdMinPrice', { price: priceStr }), ...(radarSignal ? [t('shareAuditMdRadar', { signal: radarLabel(radarSignal) })] : []), - t('shareAuditMdModelKey', { modelKey }), + t('shareAuditMdModelKey', { modelKey: safeModelKey }), t('shareAuditMdUrl', { url: window.location.href }), ]; await navigator.clipboard.writeText(lines.join('\n')); @@ -171,15 +199,15 @@ export default function ProductShareAuditButton({ ...(radarSignal ? RADAR_CSV_HEADERS : []), ]; const row = [ - name, - brand, - modelKey, + safeName, + safeBrand, + safeModelKey, safeUserScore5 > 0 ? safeUserScore5.toFixed(2) : '', safeExpertScore10 != null ? safeExpertScore10.toFixed(1) : '', safeAuthenticReviews, safeMinPriceVnd > 0 ? safeMinPriceVnd : '', safeOfferCount, - keySpecLine || '', + safeKeySpecLine, ...(radarSignal ? radarCsvCells(radarSignal) : []), ]; const csvContent = `\uFEFF${headers.map(escapeCsvCell).join(',')}\n${row.map(escapeCsvCell).join(',')}`; @@ -198,15 +226,15 @@ export default function ProductShareAuditButton({ source: 'Rate.vn product summary', exportedAt: new Date().toISOString(), product: { - name, - brand, - modelKey, + name: safeName, + brand: safeBrand, + modelKey: safeModelKey, userScore5: safeUserScore5 > 0 ? safeUserScore5 : null, expertScore10: safeExpertScore10, authenticReviews: safeAuthenticReviews, minPriceVnd: safeMinPriceVnd, offerCount: safeOfferCount, - keySpecLine: keySpecLine || null, + keySpecLine: safeKeySpecLine || null, dealRadar: radarSignal ? radarExportFields(radarSignal) : null, }, }; diff --git a/apps/web/src/components/leaderboard/ProductValueCalculator.tsx b/apps/web/src/components/leaderboard/ProductValueCalculator.tsx index 7364aa50..ee1394d4 100644 --- a/apps/web/src/components/leaderboard/ProductValueCalculator.tsx +++ b/apps/web/src/components/leaderboard/ProductValueCalculator.tsx @@ -25,6 +25,25 @@ interface ProductValueCalculatorProps { export type HorizonMonths = 12 | 24 | 36; +export function sanitizeValueCriterionScore5(value: unknown): number { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num) || num <= 0) return 0; + return Number(Math.min(5, num).toFixed(1)); +} + +export function sanitizeValueCalcFileToken(value: unknown): string { + if (typeof value !== 'string') return 'devices'; + const cleaned = value + .replace(/\p{Cc}+/gu, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return cleaned || 'devices'; +} + export function estimateResaleRetentionRatio( brand: string, categorySlug: string, @@ -172,16 +191,9 @@ export default function ProductValueCalculator({ return null; } - const rawValueScore = Number(valueCriterionScore5); const safeValueCriterionScore5 = - Number.isFinite(rawValueScore) && rawValueScore > 0 - ? Math.min(5, rawValueScore) - : 0; - const safeCategoryFileToken = - (typeof categorySlug === 'string' ? categorySlug : '') - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'devices'; + sanitizeValueCriterionScore5(valueCriterionScore5); + const safeCategoryFileToken = sanitizeValueCalcFileToken(categorySlug); const handleDownloadValueCsv = () => { if (typeof window === 'undefined') return; diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 5cfa3957..6a6ae856 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -12852,6 +12852,73 @@ async function runCoreArchitectureSuite() { ); } + // ------------------------------------------------------------------------- + // 126h. ProductOffersExplorer, ProductShareAuditButton & ProductValueCalculator Bounds (#1055) + // ------------------------------------------------------------------------- + { + const { + isSafeExternalOfferUrl, + sanitizeOfferRiskScore100, + sanitizeOffersMarkdownCell, + sanitizeOffersFileToken, + } = await import('../../../components/leaderboard/ProductOffersExplorer'); + const { normalizeShareAuditFields, sanitizeShareAuditText } = + await import('../../../components/leaderboard/ProductShareAuditButton'); + const { sanitizeValueCalcFileToken, sanitizeValueCriterionScore5 } = + await import('../../../components/leaderboard/ProductValueCalculator'); + + assert.equal(isSafeExternalOfferUrl('https://shopee.vn/item-i.1.2'), true); + assert.equal( + isSafeExternalOfferUrl('https://user:pass@evil.example/item'), + false + ); + assert.equal(isSafeExternalOfferUrl('https://shopee.vn/item\u0000'), false); + assert.equal(isSafeExternalOfferUrl('javascript:alert(1)'), false); + assert.equal(sanitizeOfferRiskScore100(145.6), 100); + assert.equal(sanitizeOfferRiskScore100(-12), 0); + assert.equal( + sanitizeOffersMarkdownCell(' Shop\r\nMall | Official\u0000 '), + 'Shop Mall - Official' + ); + assert.equal( + sanitizeOffersFileToken(' ../iPhone 16 Pro\u0000!! '), + 'iphone-16-pro' + ); + assert.equal(sanitizeOffersFileToken(' '), 'audit'); + + const auditFields = normalizeShareAuditFields({ + modelKey: ' ../apple:iphone-16\u0000!! ', + userScore5: 7.5, + expertScore10: 14, + authenticReviews: 50_000_000, + minPriceVnd: 9_000_000_000, + offerCount: -5, + }); + assert.equal(auditFields.safeModelKeyToken, 'apple-iphone-16'); + assert.equal(auditFields.safeUserScore5, 5); + assert.equal(auditFields.safeExpertScore10, 10); + assert.equal(auditFields.safeAuthenticReviews, 10_000_000); + assert.equal(auditFields.safeMinPriceVnd, 0); + assert.equal(auditFields.safeOfferCount, 0); + assert.equal( + sanitizeShareAuditText(' iPhone\r\n16 | Pro\u0000 ', 160), + 'iPhone 16 - Pro' + ); + + assert.equal(sanitizeValueCriterionScore5(4.84), 4.8); + assert.equal(sanitizeValueCriterionScore5(9.2), 5); + assert.equal(sanitizeValueCriterionScore5(-2), 0); + assert.equal( + sanitizeValueCalcFileToken(' ../Smartphones\u0000!! '), + 'smartphones' + ); + assert.equal(sanitizeValueCalcFileToken(' '), 'devices'); + + console.log( + ' ✅ [126h/135] ProductOffersExplorer, ProductShareAuditButton & ProductValueCalculator Bounds (#1055) passed' + ); + } + // ------------------------------------------------------------------------- // 127. Rate Server Index & Extension Entity, Slug, and Category Bounds (#1029) // ------------------------------------------------------------------------- From c11daf9b69a5feda4a26f8838b80bc14b8a96b1e Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:16:35 +0700 Subject: [PATCH 3/5] fix(a11y): label the /inspect link input (#1088) (#1095) The input was named only by its placeholder, and the page pre-fills it with a sample URL, so no caption was ever visible. Give it an aria-label from a new inspect.inputAria key and cover /inspect, /contact-us and /compare in the input-labels spec. Co-authored-by: JOY Co-authored-by: Claude Opus 5.5 --- apps/web/e2e/input-labels.spec.ts | 12 +++++++++++- apps/web/locales/cs.json | 1 + apps/web/locales/en.json | 1 + apps/web/locales/vi.json | 1 + .../src/components/inspect/DeepInspectWorkspace.tsx | 3 +++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/input-labels.spec.ts b/apps/web/e2e/input-labels.spec.ts index 8ca2c665..bcbb7876 100644 --- a/apps/web/e2e/input-labels.spec.ts +++ b/apps/web/e2e/input-labels.spec.ts @@ -3,7 +3,17 @@ import { expect, test } from '@playwright/test'; // A placeholder is not a label: it disappears as soon as the user types. // Every visible field must be named by a