mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(places): cap the Naver list-import response like the Google one
The place fold capped the Google list-import body at 8 MB but left the Naver importer reading its pages unbounded — same attacker-influenced URL (the folder id), and worse in practice because that path is a pager that buffers a fresh body on every iteration. fetchPage now checks the declared content-length before reading and the actual length after, returning the existing 'Failed to fetch list from Naver Maps' 502 on either. Reading the body as text and parsing it explicitly (rather than apiRes.json()) is what makes the post-read check possible; the malformed-payload branch keeps its 'Invalid list data received from Naver Maps' 400 because the JSON.parse stays inside the same try. The post-read check is the one that matters for a hostile server: a chunked response carries no content-length, so the declared check alone is evadable. Covered for both providers now — PLACE-SVC-073b (Google, chunked) and PLACE-SVC-074..077 (Naver: declared, chunked, malformed, happy path). The five Naver fetch mocks in the integration suite move from json() to text().
This commit is contained in:
@@ -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<string, unknown>[];
|
||||
};
|
||||
|
||||
@@ -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' }],
|
||||
}),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user