Skip to content

fix: bump next 16.0.0 → 16.0.11 (CVE-2026-23864 / GHSA-h25m-26qc-wcjf DoS)#1

Open
Leathal1 wants to merge 128 commits into
caffo:mainfrom
Leathal1:fix/next-16.0.11-dos-cve-2026-23864
Open

fix: bump next 16.0.0 → 16.0.11 (CVE-2026-23864 / GHSA-h25m-26qc-wcjf DoS)#1
Leathal1 wants to merge 128 commits into
caffo:mainfrom
Leathal1:fix/next-16.0.11-dos-cve-2026-23864

Conversation

@Leathal1

Copy link
Copy Markdown

Security Fix: CVE-2026-23864 / GHSA-h25m-26qc-wcjf

Vulnerability: Next.js HTTP request deserialization can lead to DoS when using insecure React Server Components. A specially crafted HTTP request to any App Router Server Function endpoint can trigger excessive CPU usage and memory exhaustion.

Affected: next 16.0.0 (range: 16.0.0-beta.0 – 16.0.11)
Fixed in: next 16.0.11

Reachability Analysis

SMRY is vulnerable:

  • Uses Next.js App Router with server components (app/proxy/page.tsx calls headers(), generateMetadata, async fetch)
  • Has API routes exposed to HTTP (/api/article, /api/summary, /api/jina)
  • All endpoints process user-controlled HTTP request data through Next.js deserialization pipeline

Changes

  • Bump next from 16.0.0 to 16.0.11 in package.json
  • Regenerate pnpm-lock.yaml
  • Remove deprecated pnpm.overrides field from package.json (pnpm 10+ ignores it; config belongs in pnpm-workspace.yaml)

Verification

Build fails both before and after the change due to pre-existing Turbopack issues (thread-stream test files picked up by bundler — unrelated to this fix). The version bump is a patch-level upgrade with no API changes.

References

mrmps and others added 28 commits October 27, 2025 14:32
…m-26qc-wcjf)

Next.js HTTP request deserialization DoS vulnerability affects App Router
server function endpoints. SMRY uses App Router with server components
(app/proxy/page.tsx uses headers(), generateMetadata, async data fetching)
and API routes — all reachable via HTTP requests.

Affected version: next 16.0.0 (range: 16.0.0-beta.0 to 16.0.11)
Fixed version: next 16.0.11

Also removes deprecated pnpm.overrides field from package.json (pnpm 10+
ignores it with a warning; overrides should go in pnpm-workspace.yaml).
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Multi-source article extraction with intelligent fallback (Diffbot, Readability, Wayback, Jina.ai)
    • OpenAI-powered summaries with per-IP rate limiting
    • Redis-backed caching for improved performance
    • Bookmarklet for quick article summarization
    • Social sharing functionality
    • FAQ section on homepage
  • Removed Features

    • Email feedback form removed
  • Bug Fixes

    • Improved route guards to prevent internal path conflicts
    • Enhanced error handling and validation across extraction pipeline
  • Documentation

    • Comprehensive architecture documentation added
    • Validation flow documentation with coverage summary

Walkthrough

The SMRY.ai codebase is overhauled from a Next.js 14 edge-runtime streaming architecture to Next.js 16 with parallel multi-source article fetching. Old KV/Resend/proxy/direct/wayback routes are removed and replaced with consolidated /api/article, /api/jina, and /api/summary routes backed by Upstash Redis. TanStack Query drives client-side parallel fetching across four sources (smry-fast, smry-slow, wayback, jina.ai). A discriminated AppError type system with Zod schemas and neverthrow Result types replaces ad-hoc error handling. The proxy page is redesigned with tabbed article content, a responsive summary drawer, and debug panel UI.

Changes

Article Fetching, Caching, and Summarization Pipeline

Layer / File(s) Summary
API contracts, error model, and Diffbot type declarations
types/api.ts, lib/errors/types.ts, lib/errors/index.ts, types/diffbot.d.ts
Introduces Zod schemas for all API request/response shapes (Source, Article, ArticleResponse, ErrorResponse, SummaryRequest/Response, Jina cache schemas), a discriminated AppError union with nine variants plus factory functions and UI helpers (getErrorMessage, getErrorTitle, isRetryableError), DebugContext/DebugStep interfaces, and TypeScript declarations for the diffbot npm module.
Server-side article fetchers: Diffbot pipeline and Jina.ai client
lib/api/diffbot.ts, lib/api/jina.ts
fetchArticleWithDiffbot uses Zod to validate new (objects[]) and old (direct-property) Diffbot response formats, falling back to a multi-selector JSDOM + Readability extraction from the raw DOM, accumulating debug steps throughout and returning ResultAsync<DiffbotArticle, AppError>. fetchJinaArticle fetches markdown from r.jina.ai, extracts content boundaries, and converts to HTML via showdown.
Consolidated API routes: /api/article, /api/jina, /api/summary
app/api/article/route.ts, app/api/jina/route.ts, app/api/summary/route.ts
Replaces removed direct, proxy, and wayback.ts routes. /api/article validates params, reads Upstash Redis with a 4000-char minimum, dispatches to fetchArticleWithSmryFast (JSDOM+Readability) or fetchArticleWithDiffbot, and applies saveOrReturnLongerArticle cache logic. /api/jina provides GET (cache lookup) and POST (cache update by length comparison). /api/summary applies per-IP rate limiting (20/day, 6/min), Redis caching, and OpenAI gpt-5-nano generation.
Client API layer and React Query hooks
lib/api/client.ts, lib/hooks/use-articles.ts, lib/hooks/use-summary.ts, lib/hooks/use-local-storage.ts, lib/hooks/use-media-query.ts
ArticleFetchError captures debugContext from error responses. articleAPI.getArticle performs the typed fetch. useJinaArticle implements a cache-first GET then fresh fetch with background POST. useArticles runs four parallel queries and aggregates loading/error/success flags. useAutoSummary and useRegenerateSummary wrap /api/summary via useQuery/useMutation.
Proxy page, ProxyContent, article rendering UI
app/proxy/page.tsx, components/features/proxy-content.tsx, components/article/content.tsx, components/article/tabs.tsx, components/article/length.tsx, components/features/summary-form.tsx, components/features/responsive-drawer.tsx, components/features/share-button.tsx
app/proxy/page.tsx adds generateMetadata (via fetchArticleForMetadata with multi-source fallback) and renders ProxyContent. ProxyContent manages markdown/iframe view-mode toggle and passes results to ArrowTabs. ArticleContent handles loading/error/content states with ShareButton, cache link, and DebugPanel. SummaryForm selects the longest eligible source, drives auto-summary, and renders results inside ResponsiveDrawer (Sheet on desktop, Drawer on mobile).
Shared UI primitives
components/shared/*, components/ui/accordion.tsx, components/ui/dialog.tsx, components/ui/drawer.tsx, components/ui/sheet.tsx, components/ui/select.tsx, components/ui/popover.tsx, components/ui/shadcn-io/*
Adds QueryProvider, ErrorBoundary, ErrorDisplay/ErrorMessage/ErrorBadge, DebugPanel, ClientOnly, and ShareButton. Adds Radix UI wrappers for Accordion, Dialog, Drawer, Sheet, Select, and Popover. Adds animated GitHubStarsButton (localStorage-cached GitHub API) and SlidingNumber (spring-animated digit roller).
Landing page, layout, and marketing updates
app/page.tsx, app/layout.tsx, app/proxy/..., app/[...slug]/page.tsx, components/marketing/*, components/layout/*
Redesigns the home page hero with GitHubStarsButton, BookmarkletLink, FAQ accordion, and breakpoint-specific submit icons. app/layout.tsx swaps Analytics for GoogleAnalytics and wraps children in QueryProvider. Proxy loading/error/layout pages are updated. Removes app/feedback/page.tsx and app/actions.ts (sendEmail). Adds Ad component, updates Banner to logo.dev images, updates SiteFooter attribution, and adds /_next//api early-exit guard in the slug catch-all route.
Infrastructure: packages, config, logger, env, docs
package.json, tsconfig.json, eslint.config.mjs, .eslintrc.json, next.config.js, .env.example, .env.production, vercel.json, lib/logger.ts, lib/errors/index.ts, README.md, VALIDATION_FLOW.md, VALIDATION_SUMMARY.md, lib/README.md
Upgrades Next.js 14→16, React 18→19; replaces @vercel/kv with @upstash/redis; adds @tanstack/react-query, motion, and Radix packages. Migrates ESLint to flat config (eslint.config.mjs). Updates tsconfig jsx to react-jsx. Extends next.config.js image config for img.logo.dev. Removes lib/data.ts, lib/fetch-with-timeout.ts, lib/format-error.ts. Updates Pino logger with env-based level and createLogger(context) factory. Adds lib/errors barrel. Rewrites README and adds validation documentation.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant ProxyPage as app/proxy/page.tsx
  participant ArticleRoute as GET /api/article
  participant JinaRoute as GET /api/jina
  participant Redis as Upstash Redis
  participant Diffbot as Diffbot API
  participant JinaAI as r.jina.ai
  participant SummaryRoute as POST /api/summary
  participant OpenAI as OpenAI gpt-5-nano

  Browser->>ProxyPage: GET /proxy?url=
  ProxyPage-->>Browser: render ProxyContent + ArrowTabs

  rect rgba(59, 130, 246, 0.5)
    note over Browser,Diffbot: Parallel client-side article fetching
    Browser->>ArticleRoute: GET ?source=smry-fast
    Browser->>ArticleRoute: GET ?source=smry-slow
    Browser->>ArticleRoute: GET ?source=wayback
    Browser->>JinaRoute: GET ?url= (cache check)
    ArticleRoute->>Redis: cache lookup
    Redis-->>ArticleRoute: miss
    ArticleRoute->>Diffbot: fetchArticleWithDiffbot
    Diffbot-->>ArticleRoute: structured article + DOM
    ArticleRoute->>Redis: write cache
    ArticleRoute-->>Browser: ArticleResponse
    JinaRoute-->>Browser: CACHE_MISS 404
    Browser->>JinaAI: fetchJinaArticle (client-side)
    JinaAI-->>Browser: markdown article
    Browser->>JinaRoute: POST /api/jina (background cache update)
  end

  rect rgba(16, 185, 129, 0.5)
    note over Browser,OpenAI: Summary generation (on demand)
    Browser->>SummaryRoute: POST /api/summary {content, language, ip}
    SummaryRoute->>SummaryRoute: rate limit check (Upstash)
    SummaryRoute->>Redis: cache key lookup
    Redis-->>SummaryRoute: miss
    SummaryRoute->>OpenAI: chat.completions.create
    OpenAI-->>SummaryRoute: summary text
    SummaryRoute->>Redis: write summary cache
    SummaryRoute-->>Browser: {summary, cached: false}
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly references the security fix (CVE-2026-23864 / GHSA-h25m-26qc-wcjf) and clearly identifies the primary change: bumping Next.js from 16.0.0 to 16.0.11 to address a DoS vulnerability.
Description check ✅ Passed The description comprehensively explains the vulnerability, its impact on SMRY, the changes made, and verification details. It is directly related to the security fix being implemented.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

app/[...slug]/page.tsx

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

app/api/article/route.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

app/api/jina/route.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

  • 50 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/page.tsx (1)

71-122: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add accessible names for the URL input and icon-only submit button.

The primary form is missing an explicit accessible label for the input and an accessible name for the submit control, which blocks assistive-tech users from reliably completing the flow.

♿ Suggested fix
           <form onSubmit={handleSubmit} className="mt-6 w-full">
+            <label htmlFor="url-input" className="sr-only">
+              Article URL
+            </label>
             <div
               className={`${
                 urlError ? "border-red-500" : ""
               } flex overflow-hidden rounded-lg border border-[`#E5E5E5`] bg-white shadow-sm focus-within:border-purple-500 focus-within:ring-4 focus-within:ring-purple-200 focus-within:ring-offset-0`}
             >
               <input
+                id="url-input"
                 className="w-full rounded-l-lg bg-transparent p-4 py-3 shadow-lg focus:outline-none"
                 autoFocus
                 autoComplete="off"
                 placeholder="https://example.com/page"
+                aria-invalid={urlError}
                 name="url"
                 value={url}
                 onChange={(e) => {
                   setUrl(e.target.value);
                   if (urlError) setUrlError(false);
                 }}
               />
               <button
                 className="cursor-pointer rounded-r-lg px-4 py-2 font-mono transition-all duration-300 ease-in-out"
                 type="submit"
+                aria-label="Generate summary"
                 onMouseEnter={() => setIsHovered(true)}
                 onMouseLeave={() => setIsHovered(false)}
               >
+                <span className="sr-only">Generate summary</span>
                 {/* Icon here */}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` around lines 71 - 122, The URL input field and the submit
button are missing accessible names that screen readers need. Add an aria-label
attribute to the input element with name="url" that describes its purpose (e.g.,
"Enter website URL"), and add an aria-label attribute to the button element with
type="submit" that describes the submit action (e.g., "Submit URL"). This
ensures assistive technology users can understand the form's purpose and
functionality.
🟠 Major comments (21)
VALIDATION_SUMMARY.md-227-233 (1)

227-233: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove machine-specific absolute paths from repository docs.

These lines expose a local username/path and make the doc non-portable. Use repo-relative paths instead.

Suggested doc patch
-1. `/Users/michaelryaboy/projects/13ft/lib/api/diffbot.ts`
+1. `lib/api/diffbot.ts`
...
-2. `/Users/michaelryaboy/projects/13ft/app/api/article/route.ts`
+2. `app/api/article/route.ts`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@VALIDATION_SUMMARY.md` around lines 227 - 233, In VALIDATION_SUMMARY.md,
replace the absolute paths that include the full machine path (starting with
/Users/michaelryaboy/projects/13ft) with repository-relative paths. For example,
change /Users/michaelryaboy/projects/13ft/lib/api/diffbot.ts to
lib/api/diffbot.ts and
/Users/michaelryaboy/projects/13ft/app/api/article/route.ts to
app/api/article/route.ts. This makes the documentation portable across different
machines and users.
package.json-70-87 (1)

70-87: ⚠️ Potential issue | 🟠 Major

Declare eslint-plugin-react-hooks explicitly in devDependencies to avoid lint bootstrap failures.

eslint-plugin-react-hooks is imported on line 3 of eslint.config.mjs but not declared in package.json. Under pnpm, relying on transitive dependency resolution is brittle and can break npm run lint/CI when the transitive path changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 70 - 87, The eslint-plugin-react-hooks package is
imported in eslint.config.mjs but is missing from the devDependencies section in
package.json. Add eslint-plugin-react-hooks as an explicit entry in the
devDependencies object in package.json with an appropriate version number to
ensure pnpm can properly resolve this dependency and prevent lint command
failures when transitive dependency paths change.
components/shared/error-display.tsx-98-114 (1)

98-114: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate outbound link protocols before rendering user-derived URLs.

Line 102 and Line 114 render displayUrl directly into href. Since this value can originate from request-driven inputs, non-HTTP schemes (for example javascript:) can be exposed as clickable links. Whitelist http:/https: before rendering either link.

Suggested fix
+ const sanitizeHttpUrl = (value?: string): string | undefined => {
+   if (!value) return undefined;
+   try {
+     const parsed = new URL(value);
+     return parsed.protocol === "http:" || parsed.protocol === "https:"
+       ? parsed.toString()
+       : undefined;
+   } catch {
+     return undefined;
+   }
+ };
+
- const displayUrl = originalUrl || errorUrl;
+ const displayUrl = sanitizeHttpUrl(originalUrl || errorUrl);

...
- {showProxyLink && source && displayUrl && getExternalUrl(source, displayUrl) && (
+ {showProxyLink && source && displayUrl && getExternalUrl(source, displayUrl) && (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/shared/error-display.tsx` around lines 98 - 114, Add URL protocol
validation to prevent injection of malicious schemes like javascript: into the
href attributes. Create a helper function that validates URLs to ensure they
only start with http: or https: protocols. Apply this validation before
rendering both the displayUrl link (around line 102) and the external URL
returned by getExternalUrl(source, displayUrl) (around line 114). Only render
each link if the URL passes the protocol whitelist validation, ensuring that
user-derived inputs cannot be exploited for XSS attacks through URL schemes.
components/shared/debug-panel.tsx-159-161 (1)

159-161: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Debug payload is unreadable due to white-on-white text.

Line 159 applies text-white inside a white details container, so expanded JSON data is effectively invisible.

Proposed fix
-                            <pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-white">
+                            <pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-zinc-800">
                               {JSON.stringify(step.data, null, 2)}
                             </pre>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/shared/debug-panel.tsx` around lines 159 - 161, The pre element
displaying the debug JSON payload has text-white class applied, which creates
white-on-white text that is invisible against the white details container
background. In the debug-panel.tsx file where the JSON.stringify output is
rendered within the pre tag, replace the text-white class with a dark text color
class (such as text-gray-900 or text-black) to ensure the debug data is readable
against the white background.
components/shared/debug-panel.tsx-178-180 (1)

178-180: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unsafe URL parsing can crash render for malformed debug URLs.

Line 179 assumes debugContext.url is always an absolute valid URL. If it is malformed/relative, new URL(...) throws during render.

Proposed fix
+  const safeHostname = (() => {
+    try {
+      return new URL(debugContext.url).hostname;
+    } catch {
+      return debugContext.url;
+    }
+  })();
...
-              <span className="max-w-xs truncate font-mono text-gray-500" title={debugContext.url}>
-                {new URL(debugContext.url).hostname}
+              <span className="max-w-xs truncate font-mono text-gray-500" title={debugContext.url}>
+                {safeHostname}
               </span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/shared/debug-panel.tsx` around lines 178 - 180, The code directly
calls new URL(debugContext.url).hostname without handling potential errors. If
debugContext.url is malformed or relative, the new URL() constructor will throw
an exception during render, crashing the component. Wrap the URL parsing logic
in a try-catch block or add validation to safely handle the hostname extraction,
providing a fallback value (such as the raw URL or a dash) when URL parsing
fails.
components/shared/debug-panel.tsx-115-118 (1)

115-118: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Step expansion is mouse-only; keyboard users can’t toggle rows.

Line 115 uses a clickable <div> with onClick but no keyboard semantics. This blocks keyboard-only navigation of the debug details.

Proposed fix
-                    <div
-                      className="cursor-pointer bg-white px-4 py-3 transition-colors hover:bg-gray-50/50"
-                      onClick={() => toggleStep(index)}
-                    >
+                    <button
+                      type="button"
+                      className="w-full cursor-pointer bg-white px-4 py-3 text-left transition-colors hover:bg-gray-50/50"
+                      onClick={() => toggleStep(index)}
+                      aria-expanded={isExpanded}
+                    >
...
-                    </div>
+                    </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/shared/debug-panel.tsx` around lines 115 - 118, The expandable
step row uses a clickable `<div>` element with an onClick handler but lacks
keyboard accessibility semantics, preventing keyboard-only users from toggling
steps. Replace the `<div>` element at line 115 with a `<button>` element to
provide native keyboard support (Enter and Space keys will automatically trigger
the toggleStep function), and ensure the button maintains the same styling and
onClick behavior as the current implementation.
app/[...slug]/page.tsx-12-13 (1)

12-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use segment-safe route guards for internal paths.

Line 12 currently matches any slug starting with /api, so inputs like /api.example.com return early and never redirect. Guard only true internal segments (/api and /api/..., same for /_next).

Suggested fix
-    if (pathname.startsWith('/_next') || pathname.startsWith('/api')) {
+    const isNextInternal = pathname === "/_next" || pathname.startsWith("/_next/");
+    const isApiRoute = pathname === "/api" || pathname.startsWith("/api/");
+    if (isNextInternal || isApiRoute) {
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`[...slug]/page.tsx around lines 12 - 13, The pathname checks in the if
statement are too broad and will match partial segment names like
`/api.example.com`. Replace the startsWith checks for both `/_next` and `/api`
with segment-safe guards that check for either an exact match to the segment or
the segment followed by a forward slash, ensuring only true internal paths like
`/api` or `/api/...` (and `/_next` or `/_next/...`) trigger the early return,
not partial matches.
app/api/article/route.ts-136-144 (1)

136-144: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a hard timeout to the direct upstream fetch.

Line 136 performs a blocking external call with no timeout. Slow or hanging upstreams can pin server resources and stall requests.

Proposed fix
     const response = await fetch(url, {
       headers: {
         "User-Agent": "smry.ai bot/1.0 (+https://smry.ai)",
         Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
         "Accept-Language": "en-US,en;q=0.9",
       },
       cache: "no-store",
       redirect: "follow",
+      signal: AbortSignal.timeout(15_000),
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/article/route.ts` around lines 136 - 144, The fetch call to the
upstream URL in the response object assignment has no timeout specified, which
can cause requests to hang indefinitely if the upstream server is slow or
unresponsive. Add a hard timeout to this fetch call by creating an
AbortController instance, setting a timeout that aborts the controller after a
reasonable duration (such as 10-15 seconds), and passing the abort signal to the
fetch options alongside the existing headers, cache, and redirect settings.
types/api.ts-64-71 (1)

64-71: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align shared summary request schema with the server validator.

Line 65 currently allows 100 chars, but app/api/summary/route.ts enforces 2000 chars. This contract mismatch causes avoidable 400s for payloads that pass shared typing/validation (and UI paths that allow much shorter content).

Proposed fix
+export const SUMMARY_MIN_CONTENT_CHARS = 2000;
+
 export const SummaryRequestSchema = z.object({
-  content: z.string().min(100, "Content must be at least 100 characters"),
+  content: z.string().min(
+    SUMMARY_MIN_CONTENT_CHARS,
+    `Content must be at least ${SUMMARY_MIN_CONTENT_CHARS} characters`
+  ),
   title: z.string().optional(),
   url: z.string().optional(),
   ip: z.string().optional(),
   language: z.string().optional().default("en"),
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/api.ts` around lines 64 - 71, Update the minimum character validation
for the content field in the SummaryRequestSchema object in types/api.ts. Change
the z.string().min(100) constraint to z.string().min(2000) to align with the
server-side validator in app/api/summary/route.ts. This ensures that payloads
passing client-side validation will not encounter avoidable 400 errors from the
server.
lib/api/client.ts-36-42 (1)

36-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden non-OK error parsing to always throw ArticleFetchError.

Line 37 assumes JSON on every error response. If the server/proxy returns HTML or plain text, this throws a SyntaxError and skips your custom error path.

Proposed fix
     if (!response.ok) {
-      const errorData: ErrorResponse = await response.json();
+      let errorData: ErrorResponse | undefined;
+      try {
+        errorData = (await response.json()) as ErrorResponse;
+      } catch {
+        errorData = {
+          error: `HTTP error! status: ${response.status}`,
+          details: { status: response.status },
+        };
+      }
       // Throw custom error that preserves debug context
       throw new ArticleFetchError(
-        errorData.error || `HTTP error! status: ${response.status}`,
+        errorData.error || `HTTP error! status: ${response.status}`,
         errorData
       );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/api/client.ts` around lines 36 - 42, The response.json() call on line 37
assumes the error response is always valid JSON. If the server returns HTML or
plain text, this will throw a SyntaxError and bypass the ArticleFetchError path.
Wrap the response.json() call in a try-catch block, and in the catch handler,
still throw an ArticleFetchError with a fallback error message (such as
including the response status code). This ensures ArticleFetchError is always
thrown for non-OK responses regardless of the response format.
app/api/summary/route.ts-159-162 (1)

159-162: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a full-content hash for cache keys to avoid summary collisions.

The fallback key uses only a truncated prefix (content.substring(0, 500)), so different articles with similar starts can share a key and receive the wrong cached summary.

Proposed fix
+import { createHash } from "node:crypto";
@@
-    const cacheKey = url 
-      ? `summary:${language}:${url}`
-      : `summary:${language}:${Buffer.from(content.substring(0, 500)).toString('base64').substring(0, 50)}`;
+    const contentHash = createHash("sha256").update(content).digest("base64url");
+    const cacheKey = url
+      ? `summary:${language}:${url}`
+      : `summary:${language}:${contentHash}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/summary/route.ts` around lines 159 - 162, The cache key generation in
the fallback case (when url is not provided) uses only a truncated substring
prefix of the content converted to base64, which can cause different articles
with similar beginnings to collide and share the same cache key. Instead of
using content.substring(0, 500) and then truncating the base64 result, generate
a full-content hash using a cryptographic hash function like crypto.createHash
with the entire content value to ensure unique cache keys for different content
and prevent incorrect cached summary retrieval.
app/api/summary/route.ts-181-193 (1)

181-193: ⚠️ Potential issue | 🟠 Major

Configure explicit timeout and maxRetries for the OpenAI API client.

The OpenAI client initialized at line 13-15 lacks explicit timeout and retry configuration, relying on SDK defaults (10-minute timeout, 2 retries). In a serverless environment, slow upstream responses can hold function capacity and degrade availability under load.

Set timeout (in milliseconds) and maxRetries during client initialization. For example:

Configuration example
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 30000, // 30 seconds
  maxRetries: 1,  // Adjust based on your requirements
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/summary/route.ts` around lines 181 - 193, The OpenAI client
initialization lacks explicit timeout and maxRetries configuration, relying on
SDK defaults which can cause performance issues in serverless environments.
Locate the OpenAI client initialization (where "new OpenAI()" is called with
apiKey configuration) and add two parameters: set timeout to an appropriate
value in milliseconds such as 30000 for 30 seconds, and set maxRetries to a
lower value like 1 to prevent long wait times that could impact function
capacity. This ensures the client has explicit, optimized configuration suitable
for your serverless environment instead of relying on SDK defaults.
app/api/jina/route.ts-134-145 (1)

134-145: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Use an atomic update for “only if longer” cache writes.

The current get → compare → set sequence is race-prone; concurrent requests can overwrite a longer article with a shorter one, violating your own update rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/jina/route.ts` around lines 134 - 145, The current implementation
uses a separate redis.get followed by a conditional redis.set, which creates a
race condition where concurrent requests can overwrite a longer cached article
with a shorter one. Replace this non-atomic get-compare-set pattern with an
atomic Redis operation, such as a Lua script or Redis transaction that performs
the length comparison and conditional update atomically on the server side. This
ensures that between reading the existing article value and deciding whether to
update it, no other request can modify the cache and invalidate your length
comparison check.
lib/api/diffbot.ts-336-344 (1)

336-344: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add an explicit timeout to Diffbot fetch calls.

This request path calls a third-party API without timeout control. A stuck upstream can hold server resources and degrade availability under load.

Suggested fix
-        const response = await fetch(apiUrl.toString());
+        const controller = new AbortController();
+        const timeout = setTimeout(() => controller.abort(), 15000);
+        const response = await fetch(apiUrl.toString(), { signal: controller.signal });
+        clearTimeout(timeout);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/api/diffbot.ts` around lines 336 - 344, The fetch call in the Diffbot API
request lacks timeout protection, which can cause requests to hang indefinitely
if the upstream service is unresponsive. Add an AbortController with a timeout
to the fetch call that instantiates the controller before the fetch invocation
and passes it to the fetch options along with a reasonable timeout duration
(typically 30 seconds for API calls). Ensure the abort signal is properly
handled in the error path so that timeout errors are caught and logged
appropriately along with the existing HTTP error handling.
lib/api/diffbot.ts-324-584 (1)

324-584: ⚠️ Potential issue | 🟠 Major

Remove async Promise executor at line 324.

new Promise(async (resolve, reject) => ...) is an anti-pattern that risks unhandled rejections and violates linting standards. Refactor to use non-async executor or Promise.resolve() to perform async work after promise construction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/api/diffbot.ts` around lines 324 - 584, The Promise constructor at the
start of this function block uses `async (resolve, reject) =>` which is an
anti-pattern that can lead to unhandled rejections. Remove the `async` keyword
from the Promise executor parameter and instead wrap all the try-catch logic in
an immediately-invoked async function (IIFE) inside the executor, or extract the
entire async logic into a separate named async function that gets invoked within
the Promise constructor. The key is to ensure the executor itself is not async
while still maintaining all the async operations (fetch, response parsing, etc.)
in the work that happens within the Promise.

Source: Linters/SAST tools

types/diffbot.d.ts-73-81 (1)

73-81: ⚠️ Potential issue | 🟠 Major

Make Diffbot callback response optional to match actual error-first callback behavior.

The current article and frontpage signatures declare response as required, but the library passes undefined for response when err is non-null. This masks real undefined-response paths and prevents TypeScript from catching unsafe access in error branches.

Suggested fix
   article(
     options: DiffbotArticleOptions,
-    callback: (err: Error | null, response: DiffbotArticleResponse) => void
+    callback: (err: Error | null, response?: DiffbotArticleResponse) => void
   ): void;

   frontpage(
     options: DiffbotFrontpageOptions,
-    callback: (err: Error | null, response: any) => void
+    callback: (err: Error | null, response?: any) => void
   ): void;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/diffbot.d.ts` around lines 73 - 81, The response parameter in the
callback signatures for both the article and frontpage methods should be
optional to match the actual library behavior where response is undefined when
an error occurs. In the article method callback, change the response type from
DiffbotArticleResponse to DiffbotArticleResponse | undefined to indicate the
response may not be present. Similarly, in the frontpage method callback, change
the response type from any to any | undefined. This will enable TypeScript to
properly enforce null-safety checks and catch unsafe access to the response
object in error-handling branches.
components/article/content.tsx-73-73 (1)

73-73: ⚠️ Potential issue | 🟠 Major

Remove allow-same-origin from iframe sandbox policies for external content.

All iframe instances (lines 73, 126, 148, 208) embed external URLs from third-party services (Wayback Machine, jina.ai). The combination of allow-same-origin with allow-scripts weakens sandbox isolation by allowing scripts to bypass CORS restrictions. This should be removed since the iframes load untrusted external content.

Suggested fix
- sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
+ sandbox="allow-scripts allow-popups allow-forms"
+ referrerPolicy="no-referrer"

Apply to lines 73, 126, 148, and 208.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/article/content.tsx` at line 73, Remove `allow-same-origin` from
the sandbox attribute on all four iframe instances that load external
third-party content (at lines 73, 126, 148, and 208 in
components/article/content.tsx). The combination of `allow-same-origin` with
`allow-scripts` creates a security vulnerability by allowing scripts to bypass
CORS restrictions on untrusted external content from Wayback Machine and
jina.ai. Keep the remaining sandbox policies (`allow-scripts`, `allow-popups`,
`allow-forms`) but remove only the `allow-same-origin` attribute from each
iframe's sandbox attribute.

Source: Linters/SAST tools

app/proxy/page.tsx-142-144 (1)

142-144: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize searchParams.url before use.

url is currently asserted as string, but the type allows string[] | undefined. This can flow invalid values into metadata fetch and page rendering logic.

Suggested fix
+function getSingleParam(value: string | string[] | undefined): string | undefined {
+  if (typeof value === "string") return value;
+  if (Array.isArray(value)) return value[0];
+  return undefined;
+}
@@
-  const url = resolvedSearchParams?.url as string;
+  const url = getSingleParam(resolvedSearchParams?.url);
@@
-  const url = resolvedSearchParams?.url as string;
+  const url = getSingleParam(resolvedSearchParams?.url);

Also applies to: 239-240

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/proxy/page.tsx` around lines 142 - 144, The url variable is being
unsafely type-asserted as a string using as string, but searchParams.url can
actually be string[] or undefined, which allows invalid values to flow into
metadata and rendering logic. Remove the unsafe type assertion and instead add
proper validation logic to normalize the value: check if url is actually a
string before using it, handle the case where it might be an array by extracting
the first element if needed, and handle the undefined case appropriately. This
validation pattern needs to be applied in both locations where this issue occurs
around lines 142-144 and 239-240 in the resolvedSearchParams variable
assignment.
lib/hooks/use-local-storage.ts-9-17 (1)

9-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deferred + unguarded localStorage hydration is causing avoidable failures and extra work.

This reads persisted state on a later tick and parses without protection. In current usage, that can trigger duplicate summary requests when stored language differs from default, and malformed storage can crash rendering.

Suggested fix
-  const [storedValue, setStoredValue] = useState(initialValue);
+  const [storedValue, setStoredValue] = useState<T>(() => {
+    if (typeof window === "undefined") return initialValue;
+    try {
+      const item = window.localStorage.getItem(key);
+      return item ? (JSON.parse(item) as T) : initialValue;
+    } catch {
+      return initialValue;
+    }
+  });

   useEffect(() => {
-    // Use a timeout to avoid setting state synchronously
-    const timer = setTimeout(() => {
-      const item = window.localStorage.getItem(key);
-      if (item) {
-        setStoredValue(JSON.parse(item));
-      }
-    }, 0);
-    return () => clearTimeout(timer);
-  }, [key]);
+    try {
+      const item = window.localStorage.getItem(key);
+      if (item) setStoredValue(JSON.parse(item) as T);
+    } catch {
+      setStoredValue(initialValue);
+    }
+  }, [key, initialValue]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/hooks/use-local-storage.ts` around lines 9 - 17, The useEffect hook in
use-local-storage.ts reads from localStorage and parses JSON without error
protection, causing crashes on malformed data. Additionally, the deferred
setTimeout approach causes unnecessary duplicate work and requests when stored
values differ from defaults. Wrap the JSON.parse call in a try-catch block to
safely handle invalid stored data, remove the deferred setTimeout to
synchronously hydrate the initial state during mount, and ensure the stored
value is properly validated before calling setStoredValue.
app/proxy/page.tsx-226-232 (1)

226-232: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

IP extraction is too narrow for proxied deployments.

Only checking x-real-ip frequently falls back to "default_ip", which can collapse unrelated users into one identity for downstream rate limiting and analytics flows.

Suggested fix
-    if (headersList && typeof headersList.get === 'function') {
-      ip = headersList.get("x-real-ip") || "default_ip";
+    if (headersList && typeof headersList.get === 'function') {
+      const forwardedFor = headersList.get("x-forwarded-for");
+      const realIp = headersList.get("x-real-ip");
+      const cfIp = headersList.get("cf-connecting-ip");
+      ip =
+        forwardedFor?.split(",")[0]?.trim() ||
+        realIp ||
+        cfIp ||
+        "default_ip";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/proxy/page.tsx` around lines 226 - 232, The IP extraction logic in the
headersList processing block only checks the x-real-ip header before falling
back to "default_ip", which causes multiple users to be grouped together in
proxied deployments. Modify the code to check multiple common headers that
contain the real IP address in priority order, such as x-forwarded-for,
cf-connecting-ip, x-client-ip, and true-client-ip, before falling back to a
default value. When a header like x-forwarded-for contains multiple IPs, extract
the first one (the client IP). This ensures more accurate IP identification
across different proxy configurations.
components/features/summary-form.tsx-34-43 (1)

34-43: ⚠️ Potential issue | 🟠 Major

Auto-summary triggers multiple API calls as source ranking changes during parallel article loads.

longestAvailableSource recalculates as articleResults updates, causing selectedSource and subsequently summaryParams to change. Since useAutoSummary keys by source (line 50 in lib/hooks/use-summary.ts), each change to the source triggers a new query with a different queryKey, resulting in multiple /api/summary calls during initial load.

Defer enabling the summary query until all article sources have settled:

Suggested fix
+  const allSourcesSettled = useMemo(
+    () => Object.values(articleResults).every((r) => r.isSuccess || r.isError),
+    [articleResults]
+  );
@@
-  const autoSummary = useAutoSummary(summaryParams, true);
+  const autoSummary = useAutoSummary(summaryParams, allSourcesSettled);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/features/summary-form.tsx` around lines 34 - 43, The
`longestAvailableSource` memo recalculates whenever `articleResults` changes
during parallel article loading, causing `selectedSource` to change multiple
times and trigger new API calls to `/api/summary` via `useAutoSummary` (which
keys queries by source). Instead of allowing the summary query to trigger on
every source change, implement a check to defer enabling the summary query until
all article sources have completed loading. This could involve tracking whether
all expected sources have loaded data or checking if the `articleResults` object
has settled (no longer being updated), then only then allowing `selectedSource`
to be used and the automatic summary to be triggered.
🟡 Minor comments (10)
VALIDATION_SUMMARY.md-245-245 (1)

245-245: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use “public APIs” instead of “public API interfaces.”

This is redundant wording and reads awkwardly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@VALIDATION_SUMMARY.md` at line 245, The phrase "public API interfaces"
contains redundant wording where "interfaces" is unnecessary. In the
VALIDATION_SUMMARY.md file, replace the text "public API interfaces" with
"public APIs" to improve clarity and readability.

Source: Linters/SAST tools

lib/README.md-7-24 (1)

7-24: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced code block.

This trips markdown lint (MD040) and is easy to fix (e.g., use ```text for the tree block).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/README.md` around lines 7 - 24, The fenced code block containing the
directory structure tree in the README.md file is missing a language identifier
after the opening triple backticks, which violates markdown lint rule MD040. Add
a language identifier (such as `text`) immediately after the opening triple
backticks to specify the type of code block content, making the syntax valid for
markdown linting tools.

Source: Linters/SAST tools

lib/README.md-97-100 (1)

97-100: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update use-articles.ts source list to match current 4-source pipeline.

The doc still describes 3 sources. Current architecture uses smry-fast, smry-slow, wayback, and jina.ai.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/README.md` around lines 97 - 100, Update the documentation for
use-articles.ts in the README to accurately reflect the current 4-source
pipeline instead of the 3-source pipeline. Replace the reference to fetching
from direct, wayback, and jina.ai sources with the actual current sources:
smry-fast, smry-slow, wayback, and jina.ai. Ensure the description of how these
sources are fetched (parallel, client-side vs server-side, caching strategy)
matches the current implementation in use-articles.ts.
VALIDATION_SUMMARY.md-124-130 (1)

124-130: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update cache terminology from “KV” to “Redis” for accuracy.

This section says “KV cache”, but the route implementation uses Upstash Redis (redis.get/redis.set). Align wording to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@VALIDATION_SUMMARY.md` around lines 124 - 130, In the Cache Read Validation
section of the documentation, update all references from "KV cache" to "Redis"
to accurately reflect that the implementation uses Upstash Redis (via
redis.get/redis.set operations) rather than generic key-value cache terminology.
Specifically, change the phrase "Validates data retrieved from KV cache" to
reference Redis instead, ensuring consistency between the documentation and the
actual redis.get/redis.set method calls used in the route implementation.
components/marketing/banner.tsx-96-97 (1)

96-97: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a fallback when NEXT_PUBLIC_LOGODEV_TOKEN is missing.

Line 97 always appends token=${logoDevToken}. If the env var is unset, the homepage emits broken logo requests (token=undefined).

Proposed fix
   const logoDevToken = process.env.NEXT_PUBLIC_LOGODEV_TOKEN;
-  const logoUrl = `https://img.logo.dev/${url}?token=${logoDevToken}&size=64&greyscale=true&format=png`;
+  const logoUrl = logoDevToken
+    ? `https://img.logo.dev/${url}?token=${logoDevToken}&size=64&greyscale=true&format=png`
+    : `https://www.google.com/s2/favicons?domain=${url}&sz=64`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/marketing/banner.tsx` around lines 96 - 97, The logoUrl
construction in the banner component always includes the token parameter even
when NEXT_PUBLIC_LOGODEV_TOKEN is undefined, resulting in broken requests with
`token=undefined`. Add a conditional check to verify that logoDevToken is
defined and non-empty before appending the token parameter to the logoUrl
string. If the token is missing, construct the URL without the token parameter
or provide an appropriate fallback mechanism.
components/ui/shadcn-io/sliding-number/index.tsx-138-141 (1)

138-141: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize number to a finite numeric value before animation math.

number accepts string; non-numeric values become NaN and leak into rolling-digit calculations.

🧮 Suggested fix
+  const numericValue = Number(number);
   const effectiveNumber = React.useMemo(
-    () => (!isInView ? 0 : Math.abs(Number(number))),
-    [number, isInView],
+    () => (!isInView ? 0 : Number.isFinite(numericValue) ? Math.abs(numericValue) : 0),
+    [numericValue, isInView],
   );
@@
-      {isInView && Number(number) < 0 && <span className="mr-1">-</span>}
+      {isInView && Number.isFinite(numericValue) && numericValue < 0 && (
+        <span className="mr-1">-</span>
+      )}

Also applies to: 209-209

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ui/shadcn-io/sliding-number/index.tsx` around lines 138 - 141, The
effectiveNumber useMemo hook does not validate that the converted number is a
finite numeric value. Since the number prop accepts strings, non-numeric input
values become NaN when passed to Math.abs and this NaN leaks into rolling-digit
calculations. Fix this by ensuring the result of Number(number) is finite using
Number.isFinite(), and provide a fallback value (such as 0) when the number is
not finite or NaN. Apply this normalization before passing the value to Math.abs
within the effectiveNumber useMemo hook.
components/ui/shadcn-io/github-stars-button/index.tsx-63-71 (1)

63-71: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid caching invalid GitHub API responses as star counts.

If GitHub returns a non-OK payload (e.g., rate-limited), stargazers_count is absent and an invalid value is cached for 5 days.

🛠️ Suggested fix
-      const response = await fetch(`https://api.github.com/repos/${username}/${repo}`);
-      const data = await response.json();
-      const starCount = data.stargazers_count as number;
+      const response = await fetch(`https://api.github.com/repos/${username}/${repo}`);
+      if (!response.ok) {
+        throw new Error(`GitHub API error: ${response.status}`);
+      }
+      const data = await response.json();
+      const starCount = Number(data?.stargazers_count);
+      if (!Number.isFinite(starCount)) {
+        throw new Error('Invalid stargazers_count in GitHub API response');
+      }
       
       // Save to localStorage
       localStorage.setItem(
         `github-stars-${username}-${repo}`,
         JSON.stringify({ stars: starCount, timestamp: Date.now() })
       );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ui/shadcn-io/github-stars-button/index.tsx` around lines 63 - 71,
The code is caching the GitHub API response without validating that the request
was successful, which means invalid responses (such as rate-limit errors) will
be cached with undefined or missing stargazers_count values. Add a check for
response.ok immediately after the fetch call to the GitHub API endpoint, and
only proceed with parsing the JSON data and caching via localStorage.setItem if
the response status is successful. If the response is not OK, handle the error
appropriately (such as throwing an error or returning early) to prevent invalid
data from being stored in the cache.
components/marketing/ad.tsx-20-27 (1)

20-27: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an accessible name to the close button.

Line 20 renders an icon-only control without an accessible label, so assistive tech users won’t know what the button does.

Suggested fix
-          <button 
+          <button
+            type="button"
+            aria-label="Dismiss advertisement"
             onClick={() => {
               // track('close banner');
               setShowAd(false);
             }} 
             className="absolute right-1 top-[8px]"
           >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/marketing/ad.tsx` around lines 20 - 27, The close button in the ad
component that contains only the XIcon lacks an accessible label for assistive
technology users. Add an aria-label attribute to the button element (the one
with className "absolute right-1 top-[8px]" that contains the XIcon) with a
descriptive label such as "Close ad" or "Close banner" to provide an accessible
name that screen readers can announce.
lib/errors/types.ts-131-230 (1)

131-230: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize optional error payload fields in factory helpers.

String(originalError) converts missing values into the literal "undefined" in multiple factories, and createParseError cannot pass debugContext even though ParseError supports it. This produces noisy technical details and drops parse-step debugging context.

Suggested fix
+const normalizeOriginalError = (originalError?: unknown): string | undefined =>
+  originalError instanceof Error
+    ? originalError.message
+    : originalError != null
+      ? String(originalError)
+      : undefined;

 export const createNetworkError = (
   message: string,
   url: string,
   statusCode?: number,
   originalError?: unknown,
   debugContext?: DebugContext
 ): NetworkError => ({
   type: "NETWORK_ERROR",
   message,
   url,
   statusCode,
-  originalError: originalError instanceof Error ? originalError.message : String(originalError),
+  originalError: normalizeOriginalError(originalError),
   debugContext,
 });

 // ...apply same normalization in createProxyError/createDiffbotError/createCacheError/createValidationError/createUnknownError

 export const createParseError = (
   message: string,
   source: string,
-  originalError?: unknown
+  originalError?: unknown,
+  debugContext?: DebugContext
 ): ParseError => ({
   type: "PARSE_ERROR",
   message,
   source,
-  originalError: originalError instanceof Error ? originalError.message : String(originalError),
+  originalError: normalizeOriginalError(originalError),
+  debugContext,
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/errors/types.ts` around lines 131 - 230, The factory functions are
inconsistently handling optional originalError fields, producing the literal
string "undefined" instead of actual undefined values, and createParseError is
missing the debugContext parameter that ParseError supports. Normalize all error
factory functions (createNetworkError, createProxyError, createDiffbotError,
createParseError, createTimeoutError, createRateLimitError, createCacheError,
createValidationError, createUnknownError) to return undefined when
originalError is falsy rather than converting it to the string "undefined".
Additionally, add debugContext as an optional parameter to createParseError and
include it in the returned ParseError object to preserve debugging context
throughout the error handling chain.
components/article/length.tsx-37-39 (1)

37-39: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle zero-word articles explicitly.

Line 37 uses a truthy check, so 0 is treated as “no value” and nothing is rendered. This hides a valid state.

Suggested fix
-  if (data?.article?.length) {
+  if (data?.article?.length != null) {
     return <>{" · " + data.article.length + " words"}</>;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/article/length.tsx` around lines 37 - 39, The truthy check on
data?.article?.length treats 0 as falsy, so articles with zero words will not
render any output. Replace the truthy check with an explicit check for null or
undefined (for example, using !== null or !== undefined, or checking typeof) to
ensure that a length value of 0 is treated as a valid state and properly
displayed as "0 words".
🧹 Nitpick comments (2)
README.md (1)

1-394: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Add language identifiers to fenced code blocks for markdown best practices.

Several code blocks lack explicit language specifications (typescript, bash, etc.). While this doesn't affect rendering, it improves linter compliance and clarity for documentation tools.

Affected blocks (estimated ranges):

  • Lines ~124–127: Cache key format
  • Lines ~147–176: Directory structure
  • Lines ~181–199: Request flow diagram
  • Lines ~202–214: Summary flow diagram
  • Lines ~267–280: URL examples
📝 Example: Adding language identifiers
- ```
+ ```text
  ┌─────────────────────────────────────────────────────────────────┐
  │                    API Request to /api/article                   │
  └────────────────────────────┬────────────────────────────────────┘

or for directory trees:

- ```
+ ```
  app/
  ├── api/
  │   ├── article/route.ts

or for cache keys:

- ```
+ ```
  summary:en:https://example.com
  summary:es:https://example.com
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 1 - 394, Several fenced code blocks in the README.md
file lack explicit language specifications, which impacts markdown linting
compliance and documentation tool clarity. Add appropriate language identifiers
to all code blocks throughout the file: use `typescript` for TypeScript code
snippets, `bash` for shell commands, `text` for diagrams and flow charts, and
similar language identifiers for other code blocks. Target the affected sections
including cache key format examples, directory structure listings, request flow
diagrams, summary flow diagrams, and URL usage examples.
VALIDATION_FLOW.md (1)

3-101: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Specify language identifier for ASCII diagram fenced code block.

Line 3 opens a code block without a language designation. For ASCII diagrams, use ```text or ```ascii to improve markdown compliance.

🎨 Proposed fix
- ```
+ ```text
  ┌─────────────────────────────────────────────────────────────────┐
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@VALIDATION_FLOW.md` around lines 3 - 101, The ASCII diagram code block in
VALIDATION_FLOW.md is missing a language identifier after the opening triple
backticks. Add a language designation such as "text" or "ascii" immediately
after the opening ``` on line 3 to improve markdown compliance and enable proper
syntax highlighting. This applies to the entire fenced code block containing the
validation flow diagram.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e80cdd8a-360f-4174-b018-bd0b33ac7314

📥 Commits

Reviewing files that changed from the base of the PR and between 9bae5db and 84fea1f.

⛔ Files ignored due to path filters (7)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/bg.png is excluded by !**/*.png
  • public/book.png is excluded by !**/*.png
  • public/grid.svg is excluded by !**/*.svg
  • public/next.svg is excluded by !**/*.svg
  • public/og-image.png is excluded by !**/*.png
  • public/vercel.svg is excluded by !**/*.svg
📒 Files selected for processing (84)
  • .env.example
  • .env.production
  • .eslintrc.json
  • README.md
  • VALIDATION_FLOW.md
  • VALIDATION_SUMMARY.md
  • app/[...slug]/page.tsx
  • app/actions.ts
  • app/api/article/route.ts
  • app/api/direct/route.ts
  • app/api/jina/route.ts
  • app/api/proxy/route.ts
  • app/api/summary/route.ts
  • app/api/wayback.ts/route.ts
  • app/feedback/page.tsx
  • app/layout.tsx
  • app/page.tsx
  • app/proxy/error.tsx
  • app/proxy/layout.tsx
  • app/proxy/loading.tsx
  • app/proxy/page.tsx
  • app/read/page.tsx
  • app/robots.txt
  • components/arrow-tabs.tsx
  • components/article-content.tsx
  • components/article/content.tsx
  • components/article/length.tsx
  • components/article/tabs.tsx
  • components/bookmarklet.tsx
  • components/dashboard-wrapper.tsx
  • components/dashboard.tsx
  • components/email-template.tsx
  • components/features/proxy-content.tsx
  • components/features/responsive-drawer.tsx
  • components/features/share-button.tsx
  • components/features/summary-form.tsx
  • components/layout/nav.tsx
  • components/layout/scroll-progress.tsx
  • components/layout/site-footer.tsx
  • components/layout/top-bar.tsx
  • components/marketing/ad.tsx
  • components/marketing/banner.tsx
  • components/marketing/bookmarklet.tsx
  • components/marketing/card-spotlight.tsx
  • components/marketing/faq.tsx
  • components/marketing/github.tsx
  • components/shared/client-only.tsx
  • components/shared/debug-panel.tsx
  • components/shared/error-boundary.tsx
  • components/shared/error-display.tsx
  • components/shared/query-provider.tsx
  • components/shared/underline-link.tsx
  • components/ui/accordion.tsx
  • components/ui/button.tsx
  • components/ui/dialog.tsx
  • components/ui/drawer.tsx
  • components/ui/popover.tsx
  • components/ui/select.tsx
  • components/ui/shadcn-io/github-stars-button/index.tsx
  • components/ui/shadcn-io/sliding-number/index.tsx
  • components/ui/sheet.tsx
  • eslint.config.mjs
  • lib/README.md
  • lib/api/client.ts
  • lib/api/diffbot.ts
  • lib/api/jina.ts
  • lib/data.ts
  • lib/errors/index.ts
  • lib/errors/safe-error.ts
  • lib/errors/types.ts
  • lib/fetch-with-timeout.ts
  • lib/format-error.ts
  • lib/hooks/use-articles.ts
  • lib/hooks/use-local-storage.ts
  • lib/hooks/use-media-query.ts
  • lib/hooks/use-scroll.ts
  • lib/hooks/use-summary.ts
  • lib/logger.ts
  • next.config.js
  • package.json
  • tsconfig.json
  • types/api.ts
  • types/diffbot.d.ts
  • vercel.json
💤 Files with no reviewable changes (17)
  • .eslintrc.json
  • app/actions.ts
  • components/bookmarklet.tsx
  • app/feedback/page.tsx
  • .env.production
  • components/article-content.tsx
  • app/api/direct/route.ts
  • components/dashboard-wrapper.tsx
  • app/api/proxy/route.ts
  • app/read/page.tsx
  • components/arrow-tabs.tsx
  • components/email-template.tsx
  • lib/fetch-with-timeout.ts
  • lib/format-error.ts
  • app/api/wayback.ts/route.ts
  • components/dashboard.tsx
  • lib/data.ts

Comment thread app/api/article/route.ts
Comment on lines +317 to +352
const validationResult = ArticleRequestSchema.safeParse({ url, source });

if (!validationResult.success) {
const error = fromError(validationResult.error);
const debugSmryUrl = url ? buildSmryUrl(url, source ?? "smry-fast") : undefined;
logger.error({ error: error.toString(), smryUrl: debugSmryUrl, url, source }, 'Validation error - Full URL for debugging');
return NextResponse.json(
ErrorResponseSchema.parse({
error: error.toString(),
type: "VALIDATION_ERROR",
}),
{ status: 400 }
);
}

const { url: validatedUrl, source: validatedSource } = validationResult.data;

// Construct the full smry.ai URL for debugging
const smryUrl = buildSmryUrl(validatedUrl, validatedSource);

// Jina.ai is handled by a separate endpoint (/api/jina) for client-side fetching
if (validatedSource === "jina.ai") {
logger.warn({ source: validatedSource, smryUrl }, 'Jina.ai source not supported in this endpoint');
return NextResponse.json(
ErrorResponseSchema.parse({
error: "Jina.ai source is handled client-side. Use /api/jina endpoint instead.",
type: "VALIDATION_ERROR",
}),
{ status: 400 }
);
}

logger.info({ source: validatedSource, hostname: new URL(validatedUrl).hostname, smryUrl }, 'API Request');

const urlWithSource = getUrlWithSource(validatedSource, validatedUrl);
const cacheKey = `${validatedSource}:${validatedUrl}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Block private/internal destinations before server-side fetch.

The URL validation path (Lines 317–352) only checks format, then smry-fast fetches it directly (Line 136). This allows SSRF against internal/metadata endpoints.

Please enforce outbound URL policy before any fetch: allow only http/https, resolve DNS, and reject loopback/link-local/private ranges (IPv4 + IPv6), localhost-style hostnames, and non-public targets.

Also applies to: 130-144

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/article/route.ts` around lines 317 - 352, The current validation
using ArticleRequestSchema.safeParse only checks URL format but does not
validate whether the URL targets a safe, public destination before server-side
fetching occurs. Add SSRF (Server-Side Request Forgery) protection after the
ArticleRequestSchema validation succeeds: validate that the URL scheme is http
or https, resolve the hostname to its IP address, and reject any URLs pointing
to loopback addresses (127.0.0.1, ::1), link-local ranges (169.254.0.0/16,
fe80::/10), private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
fc00::/7), or localhost-style hostnames. Return a 400 error with an appropriate
message if the URL fails these checks, ensuring this validation happens before
validatedUrl is used in getUrlWithSource or any subsequent fetch operations.

Comment thread app/api/jina/route.ts
Comment on lines +111 to +193
export async function POST(request: NextRequest) {
try {
const body = await request.json();

const validationResult = JinaCacheUpdateSchema.safeParse(body);

if (!validationResult.success) {
const error = fromError(validationResult.error);
logger.error({ error: error.toString() }, 'Validation error');
return NextResponse.json(
ErrorResponseSchema.parse({
error: error.toString(),
type: "VALIDATION_ERROR",
}),
{ status: 400 }
);
}

const { url, article } = validationResult.data;
const cacheKey = `jina.ai:${url}`;

logger.info({ hostname: new URL(url).hostname, length: article.length }, 'Updating Jina cache');

try {
const existingArticle = await redis.get<z.infer<typeof CachedArticleSchema>>(cacheKey);

const validatedExisting = existingArticle
? CachedArticleSchema.parse(existingArticle)
: null;

// Only update if new article is longer or doesn't exist
if (!validatedExisting || article.length > validatedExisting.length) {
await redis.set(cacheKey, article);
logger.info({ hostname: new URL(url).hostname, length: article.length }, 'Jina cache updated');

const response = ArticleResponseSchema.parse({
source: "jina.ai",
cacheURL: `https://r.jina.ai/${url}`,
article: {
...article,
byline: "",
dir: "",
lang: "",
},
status: "success",
});

return NextResponse.json(response);
} else {
logger.debug({ hostname: new URL(url).hostname, existingLength: validatedExisting.length, newLength: article.length }, 'Keeping existing Jina cache');

const response = ArticleResponseSchema.parse({
source: "jina.ai",
cacheURL: `https://r.jina.ai/${url}`,
article: {
...validatedExisting,
byline: "",
dir: "",
lang: "",
},
status: "success",
});

return NextResponse.json(response);
}
} catch (error) {
logger.warn({ error: error instanceof Error ? error.message : String(error) }, 'Jina cache update error');

// Return the article even if caching fails
const response = ArticleResponseSchema.parse({
source: "jina.ai",
cacheURL: `https://r.jina.ai/${url}`,
article: {
...article,
byline: "",
dir: "",
lang: "",
},
status: "success",
});

return NextResponse.json(response);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Lock down POST /api/jina to prevent cache poisoning (and downstream XSS risk).

This endpoint accepts unauthenticated user-controlled article payloads and persists them globally. Since cached content is later rendered as HTML in the UI flow, an attacker can poison cache entries for shared URLs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/jina/route.ts` around lines 111 - 193, The POST function in route.ts
currently accepts unauthenticated requests and directly caches user-provided
article content that can later be rendered as HTML, creating a cache poisoning
and XSS vulnerability. Add authentication and authorization checks at the
beginning of the POST function before accepting or processing the request body,
ensuring only authenticated and authorized users can invoke the cache update
functionality. Verify the user's identity and permissions before proceeding with
the validation and cache operations.

Comment thread app/api/summary/route.ts
Comment on lines +111 to +113
const { content, title, url, ip, language } = validationResult.data;
const clientIp = ip || request.headers.get("x-real-ip") || request.headers.get("x-forwarded-for") || "unknown";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Do not trust ip from request body for rate limiting.

Line 112 lets callers choose clientIp via body input, so rate limits are trivially bypassed by rotating arbitrary ip values.

Use only trusted proxy/network headers (or platform-provided request IP), and ignore body-provided IP for enforcement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/summary/route.ts` around lines 111 - 113, The clientIp assignment on
line 112 trusts the ip value from the request body (validationResult.data),
which allows attackers to bypass rate limiting by sending arbitrary ip values.
Remove the ip parameter from the rate limiting logic and only use trusted proxy
headers (x-real-ip, x-forwarded-for) or the platform-provided request IP. The
validation schema may still accept the ip parameter for other purposes, but it
should not be used for determining the client IP in the clientIp variable.

Comment on lines +236 to +239
<div
className="overflow-wrap mt-10 max-w-full break-words"
dangerouslySetInnerHTML={{ __html: content.article.content }}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=ts --type=tsx -C3 'dangerouslySetInnerHTML|article\.content|DOMPurify|sanitize-html|sanitize\('

Repository: caffo/SMRY

Length of output: 82


🏁 Script executed:

#!/bin/bash
# First, locate the content.tsx file
find . -name "content.tsx" -path "*/article/*" 2>/dev/null

Repository: caffo/SMRY

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Try a broader search for the file
find . -name "content.tsx" 2>/dev/null | head -20

Repository: caffo/SMRY

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Search for dangerouslySetInnerHTML in the codebase with simpler approach
rg -n "dangerouslySetInnerHTML" 2>/dev/null

Repository: caffo/SMRY

Length of output: 163


🏁 Script executed:

#!/bin/bash
# Read the content.tsx file to examine the code context
cat -n ./components/article/content.tsx

Repository: caffo/SMRY

Length of output: 10665


🏁 Script executed:

#!/bin/bash
# Check package.json for available security/sanitization libraries
grep -E "dompurify|sanitize|xss" package.json

Repository: caffo/SMRY

Length of output: 36


🏁 Script executed:

#!/bin/bash
# Check the ArticleResponse type definition
find . -name "*.ts" -o -name "*.tsx" | xargs grep -l "ArticleResponse" | head -5

Repository: caffo/SMRY

Length of output: 200


🏁 Script executed:

#!/bin/bash
# Search for all dangerouslySetInnerHTML uses in the codebase
rg "dangerouslySetInnerHTML" -A2 -B2

Repository: caffo/SMRY

Length of output: 392


🏁 Script executed:

#!/bin/bash
# Find and read the types/api file
find . -path "*/types/api*" -type f 2>/dev/null

Repository: caffo/SMRY

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Check the API route that returns article content
cat -n ./app/api/article/route.ts | head -100

Repository: caffo/SMRY

Length of output: 3982


🏁 Script executed:

#!/bin/bash
# Continue reading the API route
cat -n ./app/api/article/route.ts | sed -n '100,200p'

Repository: caffo/SMRY

Length of output: 4386


🏁 Script executed:

#!/bin/bash
# Read types/api.ts to see ArticleResponse type
cat -n ./types/api.ts

Repository: caffo/SMRY

Length of output: 4289


🏁 Script executed:

#!/bin/bash
# Read package.json to see dependencies
cat package.json | head -50

Repository: caffo/SMRY

Length of output: 1560


Unsanitized HTML injection enables XSS vulnerability.

Line 238 injects external article content directly into the DOM without sanitization. Article content originates from third-party web pages extracted via Mozilla Readability, which preserves all HTML markup including potentially malicious scripts. No sanitization libraries (DOMPurify, sanitize-html) are present in project dependencies, and no sanitization occurs at any point in the pipeline: fetch → Readability extraction → Redis cache → API response → client rendering.

Suggested fix
 import React from "react";
+import DOMPurify from "dompurify";
@@
   const content = data;
+  const sanitizedArticleHtml = React.useMemo(
+    () => DOMPurify.sanitize(content.article?.content ?? ""),
+    [content.article?.content]
+  );
@@
-          <div
-            className="overflow-wrap mt-10 max-w-full break-words"
-            dangerouslySetInnerHTML={{ __html: content.article.content }}
-          />
+          <div
+            className="overflow-wrap mt-10 max-w-full break-words"
+            dangerouslySetInnerHTML={{ __html: sanitizedArticleHtml }}
+          />

Note: Add DOMPurify to dependencies: npm install dompurify and npm install -D @types/dompurify``

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="overflow-wrap mt-10 max-w-full break-words"
dangerouslySetInnerHTML={{ __html: content.article.content }}
/>
import React from "react";
import DOMPurify from "dompurify";
// ... other imports and code ...
export default function YourComponent() {
// ... other code ...
const content = data;
const sanitizedArticleHtml = React.useMemo(
() => DOMPurify.sanitize(content.article?.content ?? ""),
[content.article?.content]
);
// ... other code ...
return (
<div>
{/* ... other JSX ... */}
<div
className="overflow-wrap mt-10 max-w-full break-words"
dangerouslySetInnerHTML={{ __html: sanitizedArticleHtml }}
/>
{/* ... other JSX ... */}
</div>
);
}
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 237-237: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(react-unsafe-html-injection)

🪛 React Doctor (0.5.6)

[error] 238-238: dangerouslySetInnerHTML is an XSS hole that runs attacker-controlled HTML in your users' browsers.

Render trusted content as React children so attacker-controlled HTML cannot run in users' browsers.

(no-danger)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/article/content.tsx` around lines 236 - 239, The div element using
dangerouslySetInnerHTML with content.article.content is vulnerable to XSS
attacks because the HTML from third-party sources (extracted via Mozilla
Readability) is injected without sanitization. Install DOMPurify as a
dependency, import it at the top of the content.tsx file, and then wrap the
content.article.content value with DOMPurify.sanitize() before passing it to the
dangerouslySetInnerHTML prop to remove any potentially malicious scripts or
harmful HTML elements.

Source: Linters/SAST tools

Comment thread lib/api/jina.ts
Comment on lines +84 to +90
const contentHtml = converter.makeHtml(mainContent);

const article: JinaArticle = {
title: title,
content: contentHtml,
textContent: mainContent,
length: mainContent.length,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check if the files exist and examine the code
find . -type f -name "jina.ts" | head -5
find . -type f -name "content.tsx" | head -5

Repository: caffo/SMRY

Length of output: 105


🏁 Script executed:

# Once we confirm file locations, examine the jina.ts file around lines 84-90
if [ -f "lib/api/jina.ts" ]; then
  echo "=== lib/api/jina.ts lines 80-95 ==="
  sed -n '80,95p' lib/api/jina.ts
fi

Repository: caffo/SMRY

Length of output: 544


🏁 Script executed:

# Check components/article/content.tsx for dangerouslySetInnerHTML usage
if [ -f "components/article/content.tsx" ]; then
  echo "=== Checking components/article/content.tsx ==="
  cat -n components/article/content.tsx | grep -A 5 -B 5 "dangerouslySetInnerHTML\|contentHtml\|content"
fi

Repository: caffo/SMRY

Length of output: 3884


🏁 Script executed:

#!/bin/bash
# Check imports and full context of jina.ts
head -20 lib/api/jina.ts
echo "---"
# Check if DOMPurify or any sanitization is already imported
grep -n "import\|DOMPurify\|sanitize" lib/api/jina.ts

Repository: caffo/SMRY

Length of output: 392


🏁 Script executed:

# Check package.json to see if dompurify is available
if [ -f "package.json" ]; then
  grep -A 5 -B 5 "dompurify" package.json
fi

Repository: caffo/SMRY

Length of output: 36


🏁 Script executed:

# Check the full package.json for dependencies
if [ -f "package.json" ]; then
  cat package.json | jq '.dependencies' 2>/dev/null || cat package.json | grep -A 30 '"dependencies"'
fi

Repository: caffo/SMRY

Length of output: 1781


Sanitize converted HTML before returning it to the UI.

Line 84 converts untrusted remote markdown to HTML without sanitization, and line 88 returns it unsanitized in the article object. This HTML is later rendered with dangerouslySetInnerHTML (components/article/content.tsx:238), creating an XSS vulnerability.

The proposed fix using DOMPurify is appropriate, but note that dompurify is not currently in the project's dependencies and will need to be added via npm install dompurify.

Proposed fix
 import showdown from "showdown";
+import DOMPurify from "dompurify";
@@
-    const contentHtml = converter.makeHtml(mainContent);
+    const contentHtml = DOMPurify.sanitize(
+      converter.makeHtml(mainContent),
+      { USE_PROFILES: { html: true } }
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/api/jina.ts` around lines 84 - 90, The HTML content generated by
converter.makeHtml() from untrusted remote markdown is not being sanitized
before being assigned to contentHtml and returned in the JinaArticle object's
content property, creating an XSS vulnerability since it's rendered with
dangerouslySetInnerHTML. Import DOMPurify (install it via npm install dompurify
if not already present), then sanitize the contentHtml by passing it through
DOMPurify.sanitize() before assigning it to the content property of the article
object. This ensures malicious scripts are removed from the converted HTML
before it reaches the UI.

Comment thread lib/hooks/use-summary.ts
Comment on lines +69 to +79
const mutation = useMutation<SummaryResponse, Error, SummaryParams>({
mutationFn: generateSummary,
});

return {
regenerate: mutation.mutate,
isRegenerating: mutation.isPending,
error: mutation.error,
preferredLanguage,
setPreferredLanguage,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n lib/hooks/use-summary.ts

Repository: caffo/SMRY

Length of output: 2605


🏁 Script executed:

cat -n components/features/summary-form.tsx

Repository: caffo/SMRY

Length of output: 10498


Manual regenerate does not update the rendered summary state.

useRegenerateSummary fires the mutation, but it never updates/invalidates the ["summary", url, language, source] query cache. The UI reads exclusively from useAutoSummary (line 71 of summary-form.tsx), so regeneration can complete without visible change.

Suggested fix
-import { useQuery, useMutation } from "`@tanstack/react-query`";
+import { useQuery, useMutation, useQueryClient } from "`@tanstack/react-query`";
@@
 export function useRegenerateSummary() {
+  const queryClient = useQueryClient();
@@
   const mutation = useMutation<SummaryResponse, Error, SummaryParams>({
     mutationFn: generateSummary,
+    onSuccess: (data, variables) => {
+      queryClient.setQueryData(
+        ["summary", variables.url, variables.language, variables.source],
+        data
+      );
+    },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mutation = useMutation<SummaryResponse, Error, SummaryParams>({
mutationFn: generateSummary,
});
return {
regenerate: mutation.mutate,
isRegenerating: mutation.isPending,
error: mutation.error,
preferredLanguage,
setPreferredLanguage,
};
export function useRegenerateSummary() {
const queryClient = useQueryClient();
const mutation = useMutation<SummaryResponse, Error, SummaryParams>({
mutationFn: generateSummary,
onSuccess: (data, variables) => {
queryClient.setQueryData(
["summary", variables.url, variables.language, variables.source],
data
);
},
});
return {
regenerate: mutation.mutate,
isRegenerating: mutation.isPending,
error: mutation.error,
preferredLanguage,
setPreferredLanguage,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/hooks/use-summary.ts` around lines 69 - 79, The useMutation in
useRegenerateSummary needs to invalidate the query cache after successful
regeneration so that useAutoSummary reads the updated summary data. Import
useQueryClient from React Query, then add an onSuccess callback to the
useMutation configuration that calls invalidateQueries with the query key
["summary", url, language, source] to force a refetch of the summary data after
the generateSummary mutation completes successfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants