diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 75946187f..160b99543 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -914,8 +914,22 @@ export class PlacesService { return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; } + // Same cap as the Google import: the URL is attacker-influenced via the + // folder id, and this pager buffers a fresh body on every iteration, so an + // uncapped read is worse here than there. The declared length is checked + // before the read; the post-read check covers a chunked response that + // carries no content-length at all. + const declared = Number(apiRes.headers?.get('content-length') ?? 0); + if (declared > MAX_LIST_RESPONSE_BYTES) { + return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; + } + try { - const data = await apiRes.json() as { + const rawText = await apiRes.text(); + if (rawText.length > MAX_LIST_RESPONSE_BYTES) { + return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; + } + const data = JSON.parse(rawText) as { folder?: { bookmarkCount?: number; name?: string }; bookmarkList?: Record[]; }; diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index df472caf2..8a82b0b5a 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -549,7 +549,7 @@ describe('Naver list import', () => { }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ folder: { name: 'Seoul Food', bookmarkCount: 22 }, bookmarkList: [ { name: 'SINSAJEON', px: 127.0226195, py: 37.5186363, memo: null, address: 'Sinsa-dong Seoul' }, @@ -559,7 +559,7 @@ describe('Naver list import', () => { }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ folder: { name: 'Seoul Food', bookmarkCount: 22 }, bookmarkList: [ { name: 'WAIKIKI MARKET', px: 126.8886523, py: 37.5589079, memo: null, address: 'Mapo-gu Seoul' }, @@ -633,7 +633,7 @@ describe('Naver list import', () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({ folder: { name: 'Empty List', bookmarkCount: 0 }, bookmarkList: [] }), + text: async () => JSON.stringify({ folder: { name: 'Empty List', bookmarkCount: 0 }, bookmarkList: [] }), }); vi.stubGlobal('fetch', fetchMock); @@ -656,7 +656,7 @@ describe('Naver list import', () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ folder: { name: 'No Coords', bookmarkCount: 2 }, bookmarkList: [ { name: 'Place A', px: undefined, py: undefined }, @@ -685,7 +685,7 @@ describe('Naver list import', () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ folder: { name: 'Seoul', bookmarkCount: 1 }, bookmarkList: [{ name: 'Gyeongbokgung', px: 126.9770, py: 37.5796, memo: null, address: 'Sejongno Seoul' }], }), diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index b461edbd0..e32d63ba3 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -1226,6 +1226,18 @@ describe('importGoogleList provider payload', () => { expect(result).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 }); }); + it('PLACE-SVC-073b — an over-large chunked response (no content-length) is refused after the read', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, // chunked: the declared check cannot help + text: async () => 'x'.repeat(9 * 1024 * 1024), + })); + const result = await svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456') as any; + expect(result).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 }); + }); + it('PLACE-SVC-073 — an over-large declared response is refused before it is read', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -1240,3 +1252,63 @@ describe('importGoogleList provider payload', () => { expect(text).not.toHaveBeenCalled(); }); }); + +// ── Provider-payload hardening for the Naver list import (#1745) ────────────── + +describe('importNaverList provider payload', () => { + const FOLDER_URL = 'https://map.naver.com/v5/favorite/myPlace/folder/abc123'; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PLACE-SVC-074 — an over-large declared page is refused before it is read', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const text = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h.toLowerCase() === 'content-length' ? String(20 * 1024 * 1024) : null) }, + text, + })); + const result = await svc.importNaverList(String(trip.id), FOLDER_URL) as any; + expect(result).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 }); + expect(text).not.toHaveBeenCalled(); + }); + + it('PLACE-SVC-075 — an over-large chunked page (no content-length) is refused after the read', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, // chunked: the declared check cannot help + text: async () => 'x'.repeat(9 * 1024 * 1024), + })); + const result = await svc.importNaverList(String(trip.id), FOLDER_URL) as any; + expect(result).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 }); + }); + + it('PLACE-SVC-076 — a malformed page body is still the documented 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'not-json-at-all' })); + const result = await svc.importNaverList(String(trip.id), FOLDER_URL) as any; + expect(result).toEqual({ error: 'Invalid list data received from Naver Maps', status: 400 }); + }); + + it('PLACE-SVC-077 — an ordinary page still imports', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ + folder: { name: 'Seoul', bookmarkCount: 1 }, + bookmarkList: [{ name: 'Gyeongbokgung', px: 126.977, py: 37.5796, memo: null, address: 'Sejongno' }], + }), + })); + const result = await svc.importNaverList(String(trip.id), FOLDER_URL) as any; + expect(result.listName).toBe('Seoul'); + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('Gyeongbokgung'); + }); +});