Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@ function isSafeUrl(candidate: string): boolean {
return !PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname));
}

const ALLOWED_ORIGINS = [
"https://devhub.vercel.app",
"http://localhost:3000",
"http://127.0.0.1:3000",
];

function isAllowedOrigin(request: NextRequest): boolean {
const origin = request.headers.get("origin");
if (origin) {
return ALLOWED_ORIGINS.includes(origin);
}
const referer = request.headers.get("referer");
if (referer) {
try {
return ALLOWED_ORIGINS.includes(new URL(referer).origin);
} catch {
return false;
}
}
return false;
}
Comment on lines +70 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The isAllowedOrigin function has a logic flaw: it returns false when both origin and referer headers are missing (line 83). However, the caller at line 87 treats false as "forbidden". This means legitimate requests without these headers (e.g., direct API calls, some mobile clients, or privacy-focused browsers) will be blocked.
Additionally, if this is intended as CORS protection, the implementation is incomplete - it validates the origin but doesn't set CORS response headers, which means browsers will still block the response even if the origin is allowed.

Confidence: 4/5

Suggested Fix

Consider the intended behavior:
Option 1: If requests without origin/referer should be allowed (more permissive):

Suggested change
function isAllowedOrigin(request: NextRequest): boolean {
const origin = request.headers.get("origin");
if (origin) {
return ALLOWED_ORIGINS.includes(origin);
}
const referer = request.headers.get("referer");
if (referer) {
try {
return ALLOWED_ORIGINS.includes(new URL(referer).origin);
} catch {
return false;
}
}
return false;
}
function isAllowedOrigin(request: NextRequest): boolean {
const origin = request.headers.get("origin");
if (origin) {
return ALLOWED_ORIGINS.includes(origin);
}
const referer = request.headers.get("referer");
if (referer) {
try {
return ALLOWED_ORIGINS.includes(new URL(referer).origin);
} catch {
return false;
}
// Allow requests without origin/referer (e.g., server-side calls, direct API access)
return true;
}

Option 2: If this is meant to be strict CORS protection, add proper CORS headers in the response:
After line 89, add CORS headers to the response throughout the function:

const headers = {
'Access-Control-Allow-Origin': origin || referer ? new URL(referer!).origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
};

And include these headers in all NextResponse.json() calls.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/api/link-preview/route.ts around lines 70-84, the isAllowedOrigin function returns false when both origin and referer headers are missing, which will block legitimate requests without these headers; decide on the intended behavior: if requests without origin/referer should be allowed, change line 83 to return true instead of false; if strict CORS protection is intended, keep the current logic but add proper CORS response headers (Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers) to all NextResponse.json() calls throughout the GET handler function.

📍 This suggestion applies to lines 70-84


export async function GET(request: NextRequest) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CRITICAL SECURITY REGRESSION: The origin validation check has been removed from the link preview API endpoint. This removes CSRF protection and opens the endpoint to abuse from any origin, enabling potential SSRF attacks, internal network scanning, and rate limit bypass. This reverses the security improvement that was previously implemented.

Confidence: 5/5

Suggested Fix
Suggested change
export async function GET(request: NextRequest) {
export async function GET(request: NextRequest) {
if (!isAllowedOrigin(request)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);

Restore the origin validation check that was removed. This is essential for:

  • CSRF Protection: Prevents malicious sites from using your API
  • SSRF Prevention: Limits who can trigger server-side requests
  • Rate Limit Protection: Prevents abuse from unauthorized origins
  • Resource Protection: Ensures only your application can use this endpoint
    If the origin check was removed intentionally, you MUST implement alternative security measures such as:
  1. API key authentication
  2. Rate limiting per IP address
  3. Strict URL allowlist validation
  4. Request signing/HMAC verification
Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/api/link-preview/route.ts on line 86, the origin validation check (isAllowedOrigin) has been removed from the GET handler, creating a critical security vulnerability by allowing any origin to call this API endpoint and potentially exploit it for SSRF attacks or internal network scanning; restore the origin validation check by adding back the if (!isAllowedOrigin(request)) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } block immediately after the function declaration and before processing the request, or if this was intentionally removed, implement alternative security measures such as API key authentication, strict rate limiting, and URL allowlist validation.

const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
Expand Down
343 changes: 343 additions & 0 deletions app/articles/ArticlesListingClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
"use client";

import ShinyText from "@/components/bits/ShinyText";
import {
fadeInUp,
staggerContainer,
staggerContainerFast,
} from "@/lib/animations";
import { accent, background, indigo, text } from "@/lib/colors";
import { motion } from "framer-motion";
import { Calendar, Clock, Tag } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

// Article type (must match articles-loader but client-safe — no fs)
interface ArticleCard {
slug: string;
title: string;
description: string;
banner: string;
author: string;
date: string;
tags: string[];
readingTime: string;
}

interface Props {
articles: ArticleCard[];
}

export default function ArticlesListingClient({ articles }: Props) {
return (
<div
className="min-h-screen relative"
style={{ background: background.primary }}
>
<div className="absolute inset-0 dot-bg opacity-50 pointer-events-none" />
<div
className="absolute top-0 right-0 w-[600px] h-[600px] pointer-events-none"
style={{
background: `radial-gradient(ellipse, ${indigo(0.06)} 0%, transparent 70%)`,
}}
/>

{/* Corner brackets */}
<div className="absolute top-20 left-8 hidden md:block">
<div
style={{
borderTop: `1px solid ${indigo(0.4)}`,
borderLeft: `1px solid ${indigo(0.4)}`,
width: 24,
height: 24,
}}
/>
</div>
<div className="absolute top-20 right-8 hidden md:block">
<div
style={{
borderTop: `1px solid ${indigo(0.4)}`,
borderRight: `1px solid ${indigo(0.4)}`,
width: 24,
height: 24,
}}
/>
</div>

<div className="max-w-6xl mx-auto px-6 pt-32 pb-24">
{/* Page header */}
<motion.div
variants={staggerContainer}
initial="hidden"
animate="visible"
className="mb-14 text-center"
>
<motion.div
variants={fadeInUp}
className="flex items-center justify-center gap-3 mb-4"
>
<span
style={{
color: indigo(0.5),
fontFamily: "var(--font-geist-mono)",
}}
>
{"{"}
</span>
<span
className="text-xs tracking-widest uppercase"
style={{
fontFamily: "var(--font-geist-mono)",
color: accent.indigoLightest,
}}
>
Articles
</span>
<span
style={{
color: indigo(0.5),
fontFamily: "var(--font-geist-mono)",
}}
>
{"}"}
</span>
</motion.div>

<motion.h1
variants={fadeInUp}
style={{
fontFamily: "var(--font-pixelify), 'Pixelify Sans', monospace",
fontSize: "clamp(2.2rem, 5vw, 3.8rem)",
lineHeight: 1.15,
}}
className="mb-5"
>
<span
style={{
background: `linear-gradient(135deg, ${text.primary} 0%, ${text.secondary} 40%, ${accent.indigoLightest} 70%, ${accent.violet} 100%)`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
Community{" "}
</span>
<span
style={{
color: accent.indigoLight,
WebkitTextFillColor: accent.indigoLight,
}}
>
<ShinyText
text="Articles"
className="cursor-target"
speed={3.5}
delay={1}
color={accent.indigoLight}
shineColor={accent.indigoShine}
spread={90}
direction="left"
yoyo={false}
pauseOnHover={false}
disabled={false}
/>
</span>
</motion.h1>

<motion.p
variants={fadeInUp}
className="text-sm md:text-base max-w-xl mx-auto leading-relaxed"
style={{
fontFamily: "var(--font-geist-mono), 'Geist Mono', monospace",
color: text.dim,
}}
>
Guides, releases, and deep dives written by the DevHub community.
</motion.p>

<motion.div
variants={fadeInUp}
className="mt-10 mx-auto"
style={{
height: "1px",
maxWidth: 280,
background: `linear-gradient(90deg, transparent, ${indigo(0.35)}, transparent)`,
}}
/>
</motion.div>

{/* Articles grid */}
{/* Articles grid */}
{articles.length === 0 ? (
<div
className="text-center py-24"
style={{ fontFamily: "var(--font-geist-mono)", color: text.dim }}
>
No articles yet. Check back soon.
</div>
) : (
<motion.div
variants={staggerContainerFast}
initial="hidden"
animate="visible"
className={
articles.length < 3
? "flex flex-wrap justify-center gap-6"
: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
}
>
{articles.map((article) => (
<motion.div
key={article.slug}
variants={fadeInUp}
className={
articles.length < 3
? "w-full md:w-[calc(50%-12px)] lg:w-[360px]"
: ""
}
>
<Link
href={`/articles/${article.slug}`}
className="group block h-full"
>
<motion.article
className="relative h-full flex flex-col overflow-hidden"
style={{
background: "rgba(7, 7, 15, 0.7)",
border: `1px solid ${indigo(0.12)}`,
}}
whileHover={{ y: -4 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{/* Banner image */}
<div
className="relative overflow-hidden"
style={{ height: "200px" }}
>
{article.banner ? (
<>
<Image
src={article.banner}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The img tag uses article.banner directly without validation, which could lead to XSS attacks if banner URLs are user-controlled or come from untrusted sources. Consider using Next.js Image component which provides built-in security and optimization.

Confidence: 5/5

Suggested Fix

Replace the native img tag with Next.js Image component for better security and performance. Update the import at the top and modify the image rendering:

Suggested change
src={article.banner}
import Image from "next/image";

Then replace the img tag with:

Suggested change
src={article.banner}
<Image
src={article.banner}
alt={article.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
unoptimized={article.banner.startsWith('http')}
/>

This provides automatic image optimization, lazy loading, and better security against malicious URLs.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/articles/ArticlesListingClient.tsx around line 219, the native img tag uses article.banner directly without validation which could lead to XSS if banner URLs come from untrusted sources; replace it with Next.js Image component by importing Image from "next/image" at the top, then replace the img tag with an Image component using fill prop and object-cover className, adding unoptimized prop for external URLs, to provide built-in security and optimization.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The img tag uses article.banner directly without validation, which could lead to XSS attacks if banner URLs are user-controlled or come from untrusted sources. Additionally, using native img instead of Next.js Image component misses out on automatic optimization.

Confidence: 5/5

Suggested Fix
Suggested change
src={article.banner}
import Image from "next/image";

Replace the img tag with Next.js Image component for better security and performance. Update the banner rendering section to use:

<Image
src={article.banner}
alt={article.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

This provides automatic image optimization, lazy loading, and better security through Next.js's built-in protections.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/articles/ArticlesListingClient.tsx around line 219, the component uses a native img tag with article.banner directly which poses XSS risks and misses Next.js optimizations; replace the img tag with Next.js Image component, import Image from "next/image" at the top, update the banner div to use position relative, and replace the img with <Image src={article.banner} alt={article.title} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" />, ensuring the parent div maintains its height style.

alt={article.title}
fill
Comment on lines +218 to +222

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The <img> tag is using an unvalidated article.banner URL directly, which could lead to XSS attacks if the banner URL contains malicious content or points to an untrusted source. Additionally, using native <img> instead of Next.js Image component bypasses built-in security features and optimizations.

Confidence: 5/5

Suggested Fix
Suggested change
<>
<Image
src={article.banner}
alt={article.title}
fill
<Image
src={article.banner}
alt={article.title}
width={600}
height={200}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
unoptimized={article.banner.startsWith('http')}
/>
  1. Import Image from next/image at the top of the file
  2. Replace the native <img> tag with Next.js Image component
  3. Add explicit width/height props for proper optimization
  4. Use unoptimized prop conditionally for external URLs if needed
  5. Consider validating banner URLs server-side in articles-loader.ts to ensure they come from trusted sources
Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/articles/ArticlesListingClient.tsx around line 218, the native <img> tag is being used with an unvalidated external URL (article.banner) which poses security risks and bypasses Next.js optimizations; replace it with the Next.js Image component, add the necessary import statement at the top (import Image from "next/image"), include proper width and height props, and consider adding URL validation in the articles-loader to ensure banner URLs come from trusted sources only.

📍 This suggestion applies to lines 218-222

className="object-cover transition-transform duration-500 group-hover:scale-105"
unoptimized={article.banner.startsWith("http")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The unoptimized={article.banner.startsWith("http")} prop disables Next.js image optimization for all external images, which defeats the purpose of migrating from <img> to <Image>. This causes performance issues: external images won't be resized, compressed, or converted to modern formats (WebP/AVIF), resulting in larger file sizes and slower page loads.

Confidence: 5/5

Suggested Fix

Instead of disabling optimization for external images, configure Next.js to allow external image optimization. Remove the unoptimized prop and add the external domains to your next.config.js:

// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**', // Or specify exact domains for better security
},
],
},
}

Then update the component to remove the unoptimized prop:

Suggested change
unoptimized={article.banner.startsWith("http")}
<Image
src={article.banner}
alt={article.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>

This allows Next.js to optimize all images (both local and external) while maintaining security through the remotePatterns configuration. If you need to support arbitrary external URLs, use hostname: '**', but for better security, explicitly list the allowed domains.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In app/articles/ArticlesListingClient.tsx on line 224, the unoptimized prop is set to true for all external images (those starting with "http"), which disables Next.js image optimization and causes performance issues by serving large unoptimized images; remove the unoptimized prop from the Image component and instead configure next.config.js to allow external image optimization by adding a remotePatterns configuration with the appropriate hostname patterns (either specific domains or '**' for all domains), ensuring all images benefit from Next.js's automatic optimization, resizing, and modern format conversion.

/>
<div
className="absolute inset-0"
style={{
background: `linear-gradient(to bottom, transparent 50%, rgba(7,7,15,0.85) 100%)`,
}}
/>
</>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{ background: indigo(0.06) }}
>
<span
style={{ color: indigo(0.3), fontSize: "2rem" }}
>
</span>
</div>
)}

{/* Top-right corner bracket accent */}
<div
className="absolute top-3 right-3 w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
style={{
borderTop: `1.5px solid ${indigo(0.6)}`,
borderRight: `1.5px solid ${indigo(0.6)}`,
}}
/>
</div>

{/* Card body */}
<div className="flex flex-col flex-1 p-5">
{/* Tags */}
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{article.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs"
style={{
fontFamily: "var(--font-geist-mono)",
background: indigo(0.08),
border: `1px solid ${indigo(0.18)}`,
color: accent.indigoLightest,
}}
>
<Tag className="w-2 h-2" />
{tag}
</span>
))}
</div>
)}

{/* Title */}
<h2
className="font-bold text-base mb-2 leading-snug transition-colors group-hover:text-[#a5b4fc]"
style={{
fontFamily: "var(--font-geist-mono)",
color: text.primary,
}}
>
{article.title}
</h2>

{/* Description */}
<p
className="text-xs leading-relaxed flex-1 mb-4"
style={
{
fontFamily: "var(--font-geist-mono)",
color: text.dim,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
} as React.CSSProperties
}
>
{article.description}
</p>

{/* Footer meta */}
<div
className="flex items-center justify-between text-xs pt-3"
style={{
fontFamily: "var(--font-geist-mono)",
borderTop: `1px solid ${indigo(0.08)}`,
color: text.veryDim,
}}
>
<span className="flex items-center gap-1.5">
<Calendar className="w-3 h-3" />
{article.date}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{article.readingTime}
</span>
</div>
</div>

{/* Hover bottom border accent */}
{/* <div
className="absolute bottom-0 left-0 right-0 h-0.5 origin-left scale-x-0 group-hover:scale-x-100 transition-transform duration-300"
style={{
background: `linear-gradient(90deg, ${accent.indigo}, ${accent.violet}, transparent)`,
}}
/> */}
</motion.article>
</Link>
</motion.div>
))}
</motion.div>
)}
</div>
</div>
);
}
Loading
Loading