Browse Source

refactor(news): cap feed response size at 5 MiB

Bound the response body size pulled from NEWS_FEED_URL.
Replaces `response.json()` with `response.text()` + byteLength
sanity check + `JSON.parse()` so a broken or compromised feed
endpoint cannot exhaust process memory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ryotaro Nagahara 3 weeks ago
parent
commit
75db2df71e

+ 31 - 24
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -50,6 +50,16 @@ const VALID_FEED = {
   ],
 };
 
+/** Build a Response-like mock that exposes `text()` returning the JSON-stringified body. */
+const mockResponse = (
+  body: unknown,
+  init?: { ok?: boolean; status?: number },
+) => ({
+  ok: init?.ok ?? true,
+  status: init?.status ?? 200,
+  text: () => Promise.resolve(JSON.stringify(body)),
+});
+
 describe('NewsCronService', () => {
   let service: NewsCronService;
   const originalEnv = process.env.NEWS_FEED_URL;
@@ -99,10 +109,7 @@ describe('NewsCronService', () => {
 
     test('should allow https:// URLs', async () => {
       process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(VALID_FEED),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
       await service.executeJob();
 
@@ -114,10 +121,7 @@ describe('NewsCronService', () => {
 
     test('should allow http://localhost URLs', async () => {
       process.env.NEWS_FEED_URL = 'http://localhost:8099/feed.json';
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(VALID_FEED),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
       await service.executeJob();
 
@@ -126,10 +130,7 @@ describe('NewsCronService', () => {
 
     test('should allow http://127.0.0.1 URLs', async () => {
       process.env.NEWS_FEED_URL = 'http://127.0.0.1:8099/feed.json';
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(VALID_FEED),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
       await service.executeJob();
 
@@ -138,10 +139,7 @@ describe('NewsCronService', () => {
 
     test('should upsert items on successful fetch', async () => {
       process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(VALID_FEED),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
       await service.executeJob();
 
@@ -188,10 +186,7 @@ describe('NewsCronService', () => {
           },
         ],
       };
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(feedWithVersionFilter),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithVersionFilter));
 
       await service.executeJob();
 
@@ -219,10 +214,7 @@ describe('NewsCronService', () => {
           },
         ],
       };
-      mocks.mockFetch.mockResolvedValue({
-        ok: true,
-        json: () => Promise.resolve(feedWithInvalidRegex),
-      });
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithInvalidRegex));
 
       await service.executeJob();
 
@@ -235,5 +227,20 @@ describe('NewsCronService', () => {
         'invalid-regex-item',
       );
     });
+
+    test('should skip when response body exceeds size limit (5 MiB)', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      // Build a string that exceeds 5 MiB
+      const oversizedText = 'x'.repeat(5 * 1024 * 1024 + 1);
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve(oversizedText),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteNewsItemsByExternalIds).not.toHaveBeenCalled();
+    });
   });
 });

+ 16 - 1
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -13,6 +13,13 @@ const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;
 /** HTTP fetch timeout in ms */
 const FETCH_TIMEOUT_MS = 10_000;
 
+/**
+ * Maximum response body size (5 MiB).
+ * Sanity limit for the trust boundary at the news feed adapter — caps how much
+ * an external endpoint (broken or compromised) can push into our process memory.
+ */
+const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
+
 interface FeedItem {
   id: string;
   type?: string;
@@ -106,7 +113,15 @@ export class NewsCronService extends CronService {
         return;
       }
 
-      feedJson = (await response.json()) as FeedJson;
+      const text = await response.text();
+      if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE_BYTES) {
+        logger.error(
+          `News feed response exceeds size limit (${MAX_RESPONSE_SIZE_BYTES} bytes), skipping`,
+        );
+        return;
+      }
+
+      feedJson = JSON.parse(text) as FeedJson;
     } catch (err) {
       logger.error('Error fetching news feed, keeping existing data', err);
       return;