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
40 changes: 24 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,38 @@ Open [http://localhost:3000](http://localhost:3000) and you're in.
## Project Structure

```
├── 📄 .eslintrc.json
├── 📄 .gitignore
├── 📁 .github
│ └── 📁 workflows
│ └── 📄 linter.yaml
├── 📁 .vscode
│ └── 📄 settings.json
├── 📄 CONTRIBUTING.md
├── 📄 LICENSE
├── 📄 README.md
├── 📁 app
│ ├── 📄 globals.css
│ ├── 📄 layout.tsx
│ ├── 📄 not-found.tsx
│ ├── 📄 page.tsx
│ ├── 📁 pages
│ │ ├── 📄 [slug]
│ │ ├── 📁 [slug]
│ │ │ ├── 📄 PageClient.tsx
│ │ │ ├── 📁 [subslug]
│ │ │ │ └── 📄 page.tsx
│ │ │ └── 📄 page.tsx
│ │ ├── 📄 layout.tsx
│ │ └── 📄 page.tsx
│ ├── 📁 partners
│ │ └── 📄 page.tsx
│ ├── 📁 resources
│ │ └── 📄 page.tsx
│ └── 📁 rules
│ └── 📄 page.tsx
├── 📄 components.json
├── 📁 components
│ ├── 📄 AnimatedText.tsx
│ ├── 📄 Badge.tsx
│ ├── 📄 BorderGlow.tsx
│ ├── 📄 BorderGlowButton.tsx
│ ├── 📄 Footer.tsx
│ ├── 📄 GlowButton.tsx
│ ├── 📄 Navbar.tsx
│ ├── 📄 Section.tsx
│ ├── 📄 ShinyText.tsx
│ ├── 📄 TargetCursor.tsx
│ ├── 📄 FuzzyText.tsx
│ ...
│ ├── 📁 home
│ │ ├── 📄 BelongSection.tsx
│ │ ├── 📄 CTASection.tsx
Expand All @@ -100,9 +102,9 @@ Open [http://localhost:3000](http://localhost:3000) and you're in.
│ ├── 📄 aspect-ratio.tsx
│ ├── 📄 avatar.tsx
│ ├── 📄 badge.tsx
├── 📄 breadcrumb.tsx
├── 📄 button.tsx
── ...
...
├── 📁 content
── 📄 pages.ts
├── 📁 hooks
│ └── 📄 use-toast.ts
├── 📁 lib
Expand All @@ -111,10 +113,16 @@ Open [http://localhost:3000](http://localhost:3000) and you're in.
│ ├── 📄 redirects.config.ts
│ ├── 📄 staticdata.config.ts
│ └── 📄 utils.ts
├── 📄 .eslintrc.json
├── 📄 .gitignore
├── 📄 components.json
├── 📄 CONTRIBUTING.md
├── 📄 LICENSE
├── 📄 next.config.js
├── 📄 package-lock.json
├── 📄 package.json
├── 📄 postcss.config.js
├── 📄 README.md
├── 📄 tailwind.config.ts
└── 📄 tsconfig.json
```
Expand Down Expand Up @@ -145,7 +153,7 @@ npm run format # Run Prettier

## Community

- **Discord** - [devhub.vercel.app/invite](https://devhub.vercel.app/invite)
- **Discord** - [devhub.vercel.app/join](https://devhub.vercel.app/join)
- **GitHub Org** - [github.com/open-devhub](https://github.com/open-devhub)
- **Email** - open-devhub@outlook.com

Expand Down
143 changes: 143 additions & 0 deletions app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

function extractMeta(html: string, property: string): string | null {
const patterns = [
new RegExp(
`<meta[^>]+(?:property|name)=["']${property}["'][^>]+content=["']([^"']*)["']`,
"i",
),
new RegExp(
`<meta[^>]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${property}["']`,
"i",
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) return match[1];
}
return null;
}

function extractTitleTag(html: string): string | null {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? match[1] : null;
}

function decodeHtmlEntities(str: string): string {
return str
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}

const PRIVATE_HOSTNAME_PATTERNS = [
/^localhost$/i,
/^0\.0\.0\.0$/,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./,
/^::1$/,
/^fc00:/i,
/^fe80:/i,
];
Comment on lines +37 to +48

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 SSRF protection is missing several critical private IP ranges that could be exploited:

  1. Missing 100.64.0.0/10 (Carrier-grade NAT - RFC 6598)
  2. Missing 192.0.0.0/24 (IETF Protocol Assignments)
  3. Missing 192.0.2.0/24 (TEST-NET-1)
  4. Missing 198.18.0.0/15 (Benchmark testing)
  5. Missing 198.51.100.0/24 (TEST-NET-2)
  6. Missing 203.0.113.0/24 (TEST-NET-3)
  7. Missing 224.0.0.0/4 (Multicast)
  8. Missing 240.0.0.0/4 (Reserved)
  9. Missing IPv6 loopback range (::1 is covered, but not the full ::1/128)
  10. Missing IPv6 unique local addresses (fd00::/8)
    An attacker could potentially use these ranges to bypass SSRF protection and access internal services.

Confidence: 5/5

Suggested Fix
Suggested change
const PRIVATE_HOSTNAME_PATTERNS = [
/^localhost$/i,
/^0\.0\.0\.0$/,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./,
/^::1$/,
/^fc00:/i,
/^fe80:/i,
];
const PRIVATE_HOSTNAME_PATTERNS = [
/^localhost$/i,
/^0\.0\.0\.0$/,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./,
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./,
/^192\.0\.0\./,
/^192\.0\.2\./,
/^198\.18\./,
/^198\.19\./,
/^198\.51\.100\./,
/^203\.0\.113\./,
/^22[4-9]\./,
/^23[0-9]\./,
/^24[0-9]\./,
/^25[0-5]\./,
/^::1$/,
/^::ffff:127\./i,
/^fc00:/i,
/^fd00:/i,
/^fe80:/i,
];

Add comprehensive private IP range coverage including carrier-grade NAT (100.64.0.0/10), test networks, multicast ranges (224.0.0.0/4), reserved ranges (240.0.0.0/4), and IPv6 unique local addresses (fd00::/8). This prevents attackers from exploiting gaps in SSRF protection to access internal services.

Prompt for AI

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

In app/api/link-preview/route.ts around line 37, the PRIVATE_HOSTNAME_PATTERNS array is missing several critical private IP ranges that could allow SSRF attacks to bypass protection; add patterns for 100.64.0.0/10 (carrier-grade NAT), 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 (test networks), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), ::ffff:127.0.0.0/104 (IPv4-mapped IPv6), and fd00::/8 (IPv6 unique local addresses) to ensure comprehensive SSRF protection.

📍 This suggestion applies to lines 37-48


function isSafeUrl(candidate: string): boolean {
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return false;
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return !PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname));
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");

if (!url) {
return NextResponse.json({ error: "missing url" }, { status: 400 });
}

if (!isSafeUrl(url)) {
return NextResponse.json(
{ error: "invalid or disallowed url" },
{ status: 400 },
);
}

try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const res = await fetch(url, {
signal: controller.signal,
redirect: "error",
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)",
},
});
Comment on lines +83 to +90

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 fetch request doesn't follow redirects safely and could be exploited for SSRF bypass. An attacker could use a public URL that redirects to an internal resource (e.g., a URL shortener pointing to http://localhost), bypassing initial URL validation.

Confidence: 5/5

Suggested Fix

Add redirect: 'manual' to the fetch options and validate redirect URLs before following them, or use redirect: 'error' to block all redirects:

Suggested change
const res = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)",
},
});
const res = await fetch(url, {
signal: controller.signal,
redirect: 'error',
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)",
},
});

Setting redirect: 'error' prevents the fetch from following redirects, which blocks SSRF bypass attacks where an attacker uses a public URL that redirects to internal resources. If you need to support redirects, use redirect: 'manual' and validate each redirect URL against the same SSRF protection rules.

Prompt for AI

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

In app/api/link-preview/route.ts around line 55-61, the fetch request doesn't prevent redirect-based SSRF bypass where attackers use public URLs that redirect to internal resources; add redirect: 'error' to the fetch options to block all redirects, or if redirects are needed, use redirect: 'manual' and validate each redirect URL against the same SSRF protection rules before following it.

📍 This suggestion applies to lines 55-61


clearTimeout(timeout);

if (!res.ok) {
return NextResponse.json({ error: "failed to fetch" }, { status: 502 });
}

const contentLength = res.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) {
return NextResponse.json(
{ error: "response too large" },
{ status: 502 },
);
}

const html = await res.text();

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 res.text() call has no size limit, allowing attackers to exhaust server memory by providing URLs that return extremely large responses (e.g., multi-gigabyte files). This can cause out-of-memory crashes and denial of service.

Confidence: 5/5

Suggested Fix
Suggested change
const html = await res.text();
const contentLength = res.headers.get('content-length');
if (contentLength && parseInt(contentLength) > 1024 * 1024) {
return NextResponse.json({ error: "response too large" }, { status: 502 });
}
const html = await res.text();
if (html.length > 1024 * 1024) {
return NextResponse.json({ error: "response too large" }, { status: 502 });
}

Add response size validation before and after reading the body. Check the Content-Length header if available, and verify the actual text length doesn't exceed 1MB. This prevents memory exhaustion attacks.

Prompt for AI

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

In app/api/link-preview/route.ts around line 69, the res.text() call has no size limit which allows attackers to exhaust server memory by providing URLs that return extremely large responses; add validation to check the Content-Length header before reading (if available) and verify the actual text length after reading, rejecting responses larger than 1MB to prevent memory exhaustion and DoS attacks.

if (html.length > 1024 * 1024) {
return NextResponse.json(
{ error: "response too large" },
{ status: 502 },
);
}

const rawTitle = extractMeta(html, "og:title") || extractTitleTag(html);
const rawDescription =
extractMeta(html, "og:description") || extractMeta(html, "description");
const rawImage = extractMeta(html, "og:image");

let image: string | null = null;
if (rawImage) {
try {
const resolvedImage = new URL(rawImage, url).toString();
image = isSafeUrl(resolvedImage) ? resolvedImage : null;
} catch {
image = null;
}
}

return NextResponse.json(
{
title: rawTitle ? decodeHtmlEntities(rawTitle).trim() : null,
description: rawDescription
? decodeHtmlEntities(rawDescription).trim()
: null,
image,
url,
},
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
} catch {
return NextResponse.json({ error: "failed to fetch" }, { status: 502 });
}
}
4 changes: 4 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ body {
font-family: var(--font-pixelify), "Pixelify Sans", monospace;
}

.font-mono {
font-family: monospace;
}

/* Mono font */
.font-mono-custom {
font-family: var(--font-geist-mono), "Geist Mono", monospace;
Expand Down
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Footer from "@/components/Footer";
import Navbar from "@/components/Navbar";
import Footer from "@/components/site/Footer";
import Navbar from "@/components/site/Navbar";
import type { Metadata } from "next";
import { Geist, Geist_Mono, Pixelify_Sans } from "next/font/google";
import "./globals.css";
Expand Down
2 changes: 1 addition & 1 deletion app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import FuzzyText from "@/components/FuzzyText";
import FuzzyText from "@/components/bits/FuzzyText";
import { fadeInUp } from "@/lib/animations";
import { motion } from "framer-motion";
import { ArrowLeft, ArrowRight } from "lucide-react";
Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import TargetCursor from "@/components/bits/TargetCursor";
import BelongSection from "@/components/home/BelongSection";
import CTASection from "@/components/home/CTASection";
import FeaturesSection from "@/components/home/FeaturesSection";
import HeroSection from "@/components/home/HeroSection";
import ShowcaseSection from "@/components/home/ShowcaseSection";
import StatsSection from "@/components/home/StatsSection";
import TargetCursor from "@/components/TargetCursor";

export default function Home() {
return (
Expand Down
Loading
Loading