Skip to content

Commit 379acab

Browse files
committed
fix(security): 🔒 harden CSP, SRI, input validation, and type safety
Add Content-Security-Policy meta tag whitelisting known origins. Add SRI integrity hash to Pretendard font CDN link. Replace innerHTML with DOM API in MapView marker creation. Add file size limit (500 MB) and sanitize error messages in FileUpload. Remove raw error message exposure from ErrorBoundary. Replace 'as any' with proper GoogleLocationData interface in parser. Upgrade ESLint to 10.0.1.
1 parent f9541fe commit 379acab

8 files changed

Lines changed: 263 additions & 409 deletions

File tree

package-lock.json

Lines changed: 197 additions & 384 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"@types/node": "^20",
2424
"@types/react": "^19",
2525
"@types/react-dom": "^19",
26-
"eslint": "^9",
27-
"eslint-config-next": "16.1.6",
26+
"eslint": "^10.0.1",
27+
"eslint-config-next": "^16.1.6",
2828
"tailwindcss": "^4",
2929
"typescript": "^5"
3030
}

src/app/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,15 @@ export default function RootLayout({
4545
return (
4646
<html lang="en" data-svc="travelback" data-mode="dark" data-mesh="on">
4747
<head>
48+
<meta
49+
httpEquiv="Content-Security-Policy"
50+
content="default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net; img-src 'self' blob: data: https://*.cartocdn.com https://*.openfreemap.org https://*.openstreetmap.org; connect-src 'self' https://*.cartocdn.com https://*.openfreemap.org https://*.openstreetmap.org; worker-src 'self' blob:; child-src 'self' blob:; media-src 'self' blob:;"
51+
/>
4852
<link
4953
rel="stylesheet"
5054
as="style"
5155
crossOrigin="anonymous"
56+
integrity="sha384-GIdEBaqGN9mNkDkMkzMHW8EKUqtpPIe/sLj1X7DIrnc9uPtLROJgmuDlh+3rBw0j"
5257
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
5358
/>
5459
</head>

src/components/ErrorBoundary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default class ErrorBoundary extends React.Component<
4646
{t('error.title')}
4747
</h1>
4848
<p className="text-sm mb-6" style={{ color: 'var(--t3)' }}>
49-
{this.state.error?.message || t('error.fallback')}
49+
{t('error.fallback')}
5050
</p>
5151
<div className="flex gap-3 justify-center">
5252
<button

src/components/FileUpload.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,33 @@ export default function FileUpload({ onTrackLoaded, hasTrack, onShowGoogleGuide
1919
const [loading, setLoading] = useState(false)
2020
const inputRef = useRef<HTMLInputElement>(null)
2121

22+
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500 MB
23+
const WARN_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
24+
2225
const handleFile = useCallback(async (file: File) => {
2326
setError(null)
2427
setLoading(true)
2528
try {
29+
if (file.size > MAX_FILE_SIZE) {
30+
throw new Error(t('fileUpload.fileTooLarge'))
31+
}
32+
if (file.size > WARN_FILE_SIZE) {
33+
console.warn(`[Travelback] Large file (${(file.size / 1024 / 1024).toFixed(0)} MB) — parsing may take a moment`)
34+
}
2635
const track = await parseTrackFile(file)
2736
onTrackLoaded(track)
2837
} catch (err) {
29-
setError(err instanceof Error ? err.message : t('fileUpload.parseFailed'))
38+
const message = err instanceof Error ? err.message : ''
39+
// Show known safe error messages; generic fallback for unexpected errors
40+
const safeMessages = [
41+
'Unsupported file format',
42+
'Track must contain at least 2 points',
43+
'Failed to read file',
44+
]
45+
const isSafe = safeMessages.some(m => message.includes(m))
46+
|| message === t('fileUpload.fileTooLarge')
47+
if (!isSafe) console.error('[Travelback] Parse error:', err)
48+
setError(isSafe ? message : t('fileUpload.parseFailed'))
3049
} finally {
3150
setLoading(false)
3251
}

src/components/MapView.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,19 @@ const MapView = forwardRef<MapViewHandle, MapViewProps>(function MapView(
220220
}
221221
map.fitBounds(bounds, { padding: 80, duration: 1000 })
222222

223-
// Create marker
223+
// Create marker using DOM API (avoid innerHTML for security)
224224
if (!markerEl.current) {
225225
markerEl.current = document.createElement('div')
226-
markerEl.current.innerHTML = `
227-
<div style="position:relative;width:20px;height:20px;">
228-
<div style="position:absolute;inset:0;border-radius:50%;background:${MARKER_COLOR};opacity:0.3;" class="marker-pulse"></div>
229-
<div style="position:absolute;inset:4px;border-radius:50%;background:${MARKER_COLOR};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>
230-
</div>
231-
`
226+
const wrapper = document.createElement('div')
227+
Object.assign(wrapper.style, { position: 'relative', width: '20px', height: '20px' })
228+
const pulse = document.createElement('div')
229+
pulse.className = 'marker-pulse'
230+
Object.assign(pulse.style, { position: 'absolute', inset: '0', borderRadius: '50%', background: MARKER_COLOR, opacity: '0.3' })
231+
const dot = document.createElement('div')
232+
Object.assign(dot.style, { position: 'absolute', inset: '4px', borderRadius: '50%', background: MARKER_COLOR, border: '2px solid white', boxShadow: '0 2px 6px rgba(0,0,0,0.3)' })
233+
wrapper.appendChild(pulse)
234+
wrapper.appendChild(dot)
235+
markerEl.current.appendChild(wrapper)
232236
}
233237

234238
if (markerRef.current) {

src/lib/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const translations = {
2121
'fileUpload.browse': 'Browse Files',
2222
'fileUpload.googleGuideLink': 'How to export Google Location History',
2323
'fileUpload.parseFailed': 'Failed to parse file',
24+
'fileUpload.fileTooLarge': 'File is too large (max 500 MB)',
2425

2526
// Controls
2627
'controls.pause': 'Pause',
@@ -160,6 +161,7 @@ export const translations = {
160161
'fileUpload.browse': '파일 선택',
161162
'fileUpload.googleGuideLink': 'Google 위치 기록 내보내기 방법',
162163
'fileUpload.parseFailed': '파일을 분석할 수 없습니다',
164+
'fileUpload.fileTooLarge': '파일이 너무 큽니다 (최대 500 MB)',
163165

164166
// Controls
165167
'controls.pause': '일시정지',

src/lib/parser.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,18 @@ function parseSemanticSegments(segments: Record<string, unknown>[], out: TrackPo
193193
}
194194
}
195195

196+
/* ---------- Google JSON shape -------------------------------------- */
197+
interface GoogleLocationData {
198+
locations?: Record<string, unknown>[]
199+
timelineObjects?: Record<string, unknown>[]
200+
timelineEdits?: Record<string, unknown>[]
201+
semanticSegments?: Record<string, unknown>[]
202+
[key: string]: unknown
203+
}
204+
196205
/* ---------- Main dispatcher ---------------------------------------- */
197206
function parseGoogleLocationHistory(text: string): Track {
198-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
199-
const data = JSON.parse(text) as any
207+
const data = JSON.parse(text) as GoogleLocationData | Record<string, unknown>[]
200208
const points: TrackPoint[] = []
201209

202210
// Flat array: [{ latitudeE7, ... }]
@@ -208,15 +216,15 @@ function parseGoogleLocationHistory(text: string): Track {
208216
parseRecords(data.locations, points)
209217
}
210218
// Semantic Location History (monthly): { timelineObjects: [...] }
211-
if (Array.isArray(data.timelineObjects)) {
219+
if (!Array.isArray(data) && Array.isArray(data.timelineObjects)) {
212220
parseTimelineObjects(data.timelineObjects, points)
213221
}
214222
// Timeline Edits.json: { timelineEdits: [...] }
215-
if (Array.isArray(data.timelineEdits)) {
223+
if (!Array.isArray(data) && Array.isArray(data.timelineEdits)) {
216224
parseTimelineEdits(data.timelineEdits, points)
217225
}
218226
// Phone export / new format: { semanticSegments: [...] }
219-
if (Array.isArray(data.semanticSegments)) {
227+
if (!Array.isArray(data) && Array.isArray(data.semanticSegments)) {
220228
parseSemanticSegments(data.semanticSegments, points)
221229
}
222230

@@ -234,17 +242,20 @@ function parseGoogleLocationHistory(text: string): Track {
234242

235243
function isGoogleLocationJSON(text: string): boolean {
236244
try {
237-
const data = JSON.parse(text)
245+
const data: unknown = JSON.parse(text)
238246
if (Array.isArray(data)) {
239-
const first = data[0]
240-
return first && ('latitudeE7' in first || 'latitude' in first)
247+
const first = data[0] as Record<string, unknown> | undefined
248+
return !!first && ('latitudeE7' in first || 'latitude' in first)
241249
}
242-
return (
243-
'locations' in data ||
244-
'semanticSegments' in data ||
245-
'timelineObjects' in data ||
246-
'timelineEdits' in data
247-
)
250+
if (typeof data === 'object' && data !== null) {
251+
return (
252+
'locations' in data ||
253+
'semanticSegments' in data ||
254+
'timelineObjects' in data ||
255+
'timelineEdits' in data
256+
)
257+
}
258+
return false
248259
} catch {
249260
return false
250261
}

0 commit comments

Comments
 (0)