mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(server): bound every outbound fetch
The rule is a timeout, a size cap and boundary validation on anything that leaves the process, and several call sites had only the timeout. A link preview, a maps URL resolve, a wiki fallback and a holiday lookup all read the whole body into memory, so a caller who picks the URL picks how much memory the server spends - a few concurrent multi-hundred-megabyte responses is the whole heap. Two of them also fetched more than their name suggests. resolve-url fell through to a full page GET for any host once it found no coordinates, which makes the server a general-purpose outbound proxy wearing its own IP. The weather URLs took lat/lng straight from the caller into the query string. readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and the plugin installer had each grown separately, so there is one of it now. Callers that only scan the head of a document (og: tags, a coordinate regex) keep the truncated bytes and find fewer fields; callers that must have the whole thing reject it.
This commit is contained in:
@@ -395,6 +395,31 @@ describe('getGithubReleases', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect((result as any[])[0].tag_name).toBe('v3.0.0');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-053a — clamps the paging query instead of interpolating it raw', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getGithubReleases('9999', '0');
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://api.github.com/repos/liketrek/TREK/releases?per_page=100&page=1',
|
||||
);
|
||||
|
||||
await getGithubReleases('10&per_page=999', 'abc');
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
'https://api.github.com/repos/liketrek/TREK/releases?per_page=10&page=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-053b — round-trips the paging the admin UI actually sends', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getGithubReleases('20', '2');
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://api.github.com/repos/liketrek/TREK/releases?per_page=20&page=2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkVersion ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -616,6 +616,36 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
||||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||||
});
|
||||
|
||||
it('MAPS-CID-003: does not read the page body of a non-Google resolved URL', async () => {
|
||||
// The body branch used to run for any host, turning resolve-url into an
|
||||
// authenticated outbound GET primitive.
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'x', name: null, address: {} }) };
|
||||
}
|
||||
if (u.includes('goo.gl')) return { url: 'https://internal.example.com/status' };
|
||||
return { url: 'https://internal.example.com/status', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(svc.resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 400 });
|
||||
// Only the redirect-following call went out; the page body was never fetched.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('MAPS-CID-004: skips a page body that declares more than the size cap', async () => {
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('cid=')) return { url: 'https://www.google.com/maps/place/Somewhere' };
|
||||
return {
|
||||
url: 'https://www.google.com/maps/place/Somewhere',
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(50_000_000) : null) },
|
||||
text: async () => 'x!3d40.6892!4d-74.0445y',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999')).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => {
|
||||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||||
expect(cpuMillis(() => { adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500);
|
||||
|
||||
@@ -486,6 +486,33 @@ describe('sendNtfy', () => {
|
||||
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
|
||||
});
|
||||
|
||||
it('NTFY-009a — a title carrying CR/LF is RFC 2047 encoded instead of reaching the header raw', async () => {
|
||||
// undici rejects a header value with a control character, so a trip titled
|
||||
// with a stray newline used to drop the notification for every recipient.
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
|
||||
const result = await sendNtfy(ntfyUrl, null, { ...payload, title: 'Trip\r\nX-Injected: 1' });
|
||||
|
||||
expect(result).toBe(true);
|
||||
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||
const encoded = calledOpts.headers['Title'] as string;
|
||||
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
|
||||
expect(encoded).not.toContain('\n');
|
||||
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
|
||||
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Trip\r\nX-Injected: 1');
|
||||
});
|
||||
|
||||
it('NTFY-009b — a plain ASCII title is still sent verbatim', async () => {
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
|
||||
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Rome 2026 - day 3' });
|
||||
|
||||
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||
expect(calledOpts.headers['Title']).toBe('Rome 2026 - day 3');
|
||||
});
|
||||
|
||||
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
|
||||
@@ -78,6 +78,14 @@ describe('unsplashService.searchUnsplashPhotos', () => {
|
||||
expect(await searchUnsplashPhotos(' ')).toEqual({ error: 'Search query is required', status: 400 });
|
||||
});
|
||||
|
||||
it('UNSPLASH-002a: gives the search request a deadline so a hung upstream cannot pin the caller', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await searchUnsplashPhotos('paris');
|
||||
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('UNSPLASH-003: maps a non-ok response to an error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 429, type: 'application/json', json: { errors: ['Rate limited'] } })));
|
||||
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Rate limited', status: 429 });
|
||||
@@ -217,6 +225,53 @@ describe('unsplashService.saveUnsplashCover', () => {
|
||||
expect(writtenCovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('UNSPLASH-008a: rejects on the declared length before the body is buffered', async () => {
|
||||
const arrayBuffer = vi.fn(async () => new ArrayBuffer(8));
|
||||
safeFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : String(20 * 1024 * 1024)),
|
||||
},
|
||||
arrayBuffer,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large');
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(writtenCovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('UNSPLASH-008b: stops a chunked oversized body mid-stream instead of buffering it', async () => {
|
||||
let cancelled = false;
|
||||
let served = 0;
|
||||
safeFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : null) },
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
served++;
|
||||
return { done: false, value: new Uint8Array(4 * 1024 * 1024) };
|
||||
},
|
||||
cancel: async () => { cancelled = true; },
|
||||
}),
|
||||
},
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large');
|
||||
expect(cancelled).toBe(true);
|
||||
// 15MB budget, 4MB chunks: the read stops on the fourth, not after an endless body.
|
||||
expect(served).toBe(4);
|
||||
expect(writtenCovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('UNSPLASH-008c: passes a deadline through safeFetch', async () => {
|
||||
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/jpeg', bytes: 32 }));
|
||||
await saveUnsplashCover('https://images.unsplash.com/photo-1');
|
||||
expect(safeFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it('UNSPLASH-009: throws when the download fails', async () => {
|
||||
safeFetch.mockResolvedValue(fakeRes({ ok: false, status: 404 }));
|
||||
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow(/HTTP 404/);
|
||||
|
||||
@@ -1518,6 +1518,41 @@ describe('quirk fixes', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070a: getHolidays refuses a year or country that is not a plain code', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||||
|
||||
for (const [year, country] of [['../../..', 'DE'], ['2026', 'DE/../../x'], ['20xx', 'DE'], ['2026', 'DEU']]) {
|
||||
expect(await fresh.getHolidays(year, country)).toEqual({ error: 'Failed to fetch holidays' });
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070b: getSchoolHolidayRegions refuses a country that is not an alpha-2 code', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||||
|
||||
expect(await fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR')).toEqual({
|
||||
error: 'Failed to fetch school holiday regions',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070c: a provider body over the size cap reads as the usual fetch error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(50 * 1024 * 1024) : null) },
|
||||
json: async () => [{ date: '2026-01-01' }],
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||||
|
||||
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
|
||||
expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' });
|
||||
});
|
||||
|
||||
it('VACAY-SVC-071: applyHolidayCalendars honors the cache TTL', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
|
||||
|
||||
@@ -830,3 +830,41 @@ describe('getWeather error paths', () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── coordinate boundary validation ───────────────────────────────────────────
|
||||
|
||||
describe('coordinate validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not-a-number', '10'],
|
||||
['', '10'],
|
||||
['91', '10'],
|
||||
['10', '181'],
|
||||
['10', '&forecast_days=16&latitude=0'],
|
||||
])('WEATHER-COORD-001: getWeather rejects lat=%s lng=%s with a 400 before any fetch', async (lat, lng) => {
|
||||
await expect(getWeather(lat, lng, undefined, 'en')).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WEATHER-COORD-002: getDetailedWeather rejects an out-of-range latitude', async () => {
|
||||
await expect(getDetailedWeather('120', '10', dateOffset(2), 'en')).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WEATHER-COORD-003: a body over the declared size cap is a 502, not a parsed answer', async () => {
|
||||
const date = dateOffset(5);
|
||||
const body = {
|
||||
daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0] },
|
||||
};
|
||||
const oversized = {
|
||||
...mockResponse(body),
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(5 * 1024 * 1024) : null) },
|
||||
} as unknown as Response;
|
||||
vi.mocked(fetch).mockResolvedValue(oversized);
|
||||
|
||||
await expect(getWeather('42.40', '9.41', date, 'en')).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,6 +175,27 @@ describe('wiki — GitHub fallback when no wiki is on disk', () => {
|
||||
expect(after.title).toBe(before.title);
|
||||
});
|
||||
|
||||
it('passes a deadline to every GitHub request', async () => {
|
||||
const wiki = await loadWiki(MISSING_WIKI);
|
||||
fetchSpy.mockResolvedValue(ok('# Timed'));
|
||||
|
||||
await wiki.getWikiPage('Home');
|
||||
|
||||
expect(fetchSpy.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('falls through to WikiNotFound when GitHub declares a body over the cap', async () => {
|
||||
const wiki = await loadWiki(MISSING_WIKI);
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(5 * 1024 * 1024) : null) },
|
||||
text: async () => '# Huge',
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound);
|
||||
});
|
||||
|
||||
it('404s when GitHub is unreachable and nothing is cached', async () => {
|
||||
const wiki = await loadWiki(MISSING_WIKI);
|
||||
fetchSpy.mockRejectedValue(new Error('network down'));
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { exceedsDeclaredLength, readCapped, readCappedText } from '../../../src/utils/cappedFetch';
|
||||
|
||||
/** Response stub that streams `chunks` through a reader, tracking cancellation. */
|
||||
function streamed(chunks: string[]) {
|
||||
let i = 0;
|
||||
const state = { cancelled: false };
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
headers: { get: () => null },
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () =>
|
||||
i < chunks.length
|
||||
? { done: false, value: new TextEncoder().encode(chunks[i++]) }
|
||||
: { done: true, value: undefined as unknown as Uint8Array },
|
||||
cancel: async () => { state.cancelled = true; },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('exceedsDeclaredLength', () => {
|
||||
it('CAP-001: is true when content-length is over the budget', () => {
|
||||
const res = { headers: { get: () => '2048' } };
|
||||
expect(exceedsDeclaredLength(res, 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it('CAP-002: is false when content-length is missing or under the budget', () => {
|
||||
expect(exceedsDeclaredLength({ headers: { get: () => '512' } }, 1024)).toBe(false);
|
||||
expect(exceedsDeclaredLength({ headers: { get: () => null } }, 1024)).toBe(false);
|
||||
expect(exceedsDeclaredLength({}, 1024)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCapped', () => {
|
||||
it('CAP-003: returns the whole body when it fits', async () => {
|
||||
const { res, state } = streamed(['hello ', 'world']);
|
||||
const { bytes, truncated } = await readCapped(res, 1024);
|
||||
expect(bytes.toString('utf8')).toBe('hello world');
|
||||
expect(truncated).toBe(false);
|
||||
expect(state.cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it('CAP-004: stops at the budget and cancels the reader when the body is oversized', async () => {
|
||||
const { res, state } = streamed(['aaaa', 'bbbb', 'cccc']);
|
||||
const { bytes, truncated } = await readCapped(res, 6);
|
||||
expect(bytes.length).toBe(6);
|
||||
expect(bytes.toString('utf8')).toBe('aaaabb');
|
||||
expect(truncated).toBe(true);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('CAP-005: survives a reader whose cancel rejects', async () => {
|
||||
const { res } = streamed(['aaaa', 'bbbb']);
|
||||
res.body.getReader = ((orig) => () => ({ ...orig(), cancel: async () => { throw new Error('nope'); } }))(
|
||||
res.body.getReader,
|
||||
);
|
||||
const { truncated } = await readCapped(res, 2);
|
||||
expect(truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('CAP-006: falls back to arrayBuffer when the response exposes no stream', async () => {
|
||||
const res = { arrayBuffer: async () => new TextEncoder().encode('body').buffer };
|
||||
const { bytes, truncated } = await readCapped(res, 1024);
|
||||
expect(bytes.toString('utf8')).toBe('body');
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('CAP-007: truncates the arrayBuffer fallback too', async () => {
|
||||
const res = { arrayBuffer: async () => new TextEncoder().encode('abcdef').buffer };
|
||||
const { bytes, truncated } = await readCapped(res, 3);
|
||||
expect(bytes.toString('utf8')).toBe('abc');
|
||||
expect(truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('CAP-008: falls back to text() when there is neither a stream nor arrayBuffer', async () => {
|
||||
const { bytes } = await readCapped({ text: async () => 'from text' }, 1024);
|
||||
expect(bytes.toString('utf8')).toBe('from text');
|
||||
});
|
||||
|
||||
it('CAP-009: yields an empty body for a stub with no reader at all', async () => {
|
||||
const { bytes, truncated } = await readCapped({}, 1024);
|
||||
expect(bytes.length).toBe(0);
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCappedText', () => {
|
||||
it('CAP-010: decodes the collected bytes as UTF-8', async () => {
|
||||
const { res } = streamed(['<title>', 'Hallo Grüße', '</title>']);
|
||||
const { text, truncated } = await readCappedText(res, 1024);
|
||||
expect(text).toBe('<title>Hallo Grüße</title>');
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('CAP-011a: prefers text() over arrayBuffer() when there is no stream', async () => {
|
||||
const res = {
|
||||
text: async () => '# markdown',
|
||||
arrayBuffer: async () => new ArrayBuffer(4),
|
||||
};
|
||||
const { text } = await readCappedText(res, 1024);
|
||||
expect(text).toBe('# markdown');
|
||||
});
|
||||
|
||||
it('CAP-011b: truncates the text() fallback at the budget', async () => {
|
||||
const { text, truncated } = await readCappedText({ text: async () => 'abcdef' }, 3);
|
||||
expect(text).toBe('abc');
|
||||
expect(truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('CAP-011: reports truncation and still returns the head', async () => {
|
||||
const { res } = streamed(['<title>x</title>', 'z'.repeat(100)]);
|
||||
const { text, truncated } = await readCappedText(res, 20);
|
||||
expect(truncated).toBe(true);
|
||||
expect(text.startsWith('<title>x</title>')).toBe(true);
|
||||
expect(text.length).toBe(20);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user