fix(places): pin the image and website URL fields on the way in

image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
This commit is contained in:
Maurice
2026-08-16 21:37:52 +02:00
committed by Maurice
parent 0f0bf50cd3
commit c8607ba234
15 changed files with 205 additions and 19 deletions
@@ -3332,7 +3332,7 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(screen.getByRole('button', { name: 'Open Website' }))
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank')
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank', 'noopener,noreferrer')
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(screen.getByRole('button', { name: 'Google Maps' }))
@@ -4,6 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
@@ -1871,7 +1872,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
@@ -953,7 +953,7 @@ describe('PlaceInspector', () => {
fireEvent.click(screen.getByText('Open Website').closest('button')!);
expect(open).toHaveBeenCalledTimes(2);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank', 'noopener,noreferrer']);
vi.unstubAllGlobals();
});
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -318,6 +319,10 @@ export default function PlaceInspector({
detailLng,
googleDetails?.open_now,
)
// Allow-listed rather than passed straight through: window.open runs a
// javascript: URL in this origin, and the stored value predates the check the
// server does on the way in now.
const websiteUrl = safeHttpUrl(place.website) ?? safeHttpUrl(googleDetails?.website)
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const navigationTargets = getNavigationTargets(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
@@ -513,8 +518,8 @@ export default function PlaceInspector({
)}
</>
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
{websiteUrl && (
<ActionButton onClick={() => window.open(websiteUrl, '_blank', 'noopener,noreferrer')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
<div style={{ flex: 1 }} />
@@ -643,7 +643,7 @@ describe('usePlacesSidebar context menu', () => {
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Open Website' }));
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank');
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank', 'noopener,noreferrer');
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Google Maps' }));
@@ -14,6 +14,7 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { safeHttpUrl } from '../../utils/safeUrl'
export interface PlacesSidebarProps {
tripId: number
@@ -276,7 +277,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
@@ -17,6 +17,7 @@ import PlaceRating from '../../../../components/shared/StarRating'
import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { safeHttpUrl } from '../../../../utils/safeUrl'
import { openFile } from '../../../../utils/fileDownload'
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
@@ -527,9 +528,9 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<MapIcon size={15} strokeWidth={2} />
</ActionCircle>
)}
{place.website && (
{safeHttpUrl(place.website) && (
<ActionCircle
onClick={() => window.open(place.website!, '_blank', 'noopener,noreferrer')}
onClick={() => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer')}
label={t('inspector.website')}
>
<ExternalLink size={15} strokeWidth={2} />
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { safeHttpUrl } from './safeUrl'
describe('safeHttpUrl', () => {
it('FE-UTIL-SAFEURL-001: passes an ordinary https homepage through', () => {
expect(safeHttpUrl('https://louvre.fr/en/visit')).toBe('https://louvre.fr/en/visit')
})
it('FE-UTIL-SAFEURL-002: passes plain http through — plenty of small venues still run it', () => {
expect(safeHttpUrl('http://pension-alpenblick.at')).toBe('http://pension-alpenblick.at')
})
it('FE-UTIL-SAFEURL-003: rejects javascript:, which window.open would run in this origin', () => {
expect(safeHttpUrl('javascript:fetch("/api/trips")')).toBeNull()
expect(safeHttpUrl('JavaScript:alert(1)')).toBeNull()
expect(safeHttpUrl(' javascript:alert(1)')).toBeNull()
})
it('FE-UTIL-SAFEURL-004: rejects the other schemes that are not a web page', () => {
expect(safeHttpUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
expect(safeHttpUrl('vbscript:msgbox(1)')).toBeNull()
expect(safeHttpUrl('file:///etc/passwd')).toBeNull()
})
it('FE-UTIL-SAFEURL-005: rejects a bare host, since window.open would treat it as a relative path', () => {
expect(safeHttpUrl('louvre.fr')).toBeNull()
expect(safeHttpUrl('//evil.example')).toBeNull()
})
it('FE-UTIL-SAFEURL-006: treats absent and empty as nothing to open', () => {
expect(safeHttpUrl(null)).toBeNull()
expect(safeHttpUrl(undefined)).toBeNull()
expect(safeHttpUrl('')).toBeNull()
})
})
+18
View File
@@ -0,0 +1,18 @@
import { placeWebsiteSchema } from '@trek/shared'
/**
* Allow-list a URL before handing it to window.open.
*
* A place's website is a plain string in the older contracts, and it reaches
* window.open from the inspector, both sidebar context menus and the mobile
* place sheet. `window.open('javascript:…')` does not open a page — it evaluates
* the script in a fresh document that inherits this origin, so a co-traveller
* with place_edit could plant one and wait for someone to click Website.
*
* Server-side validation closes the write path. This closes the read path, which
* is what covers rows written before that validation existed and instances that
* have not updated yet — the same split as safeHexColor.
*/
export function safeHttpUrl(value: string | null | undefined): string | null {
return typeof value === 'string' && placeWebsiteSchema.safeParse(value).success ? value : null
}