-
Notifications
You must be signed in to change notification settings - Fork 1
feat: apply special classes on regex match in pages #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e8d5928
832006d
766ba42
27de58e
b7eeb2b
33894d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(/&/g, "&") | ||||||||||||||||||||||||||||||||||
| .replace(/"/g, '"') | ||||||||||||||||||||||||||||||||||
| .replace(/'/g, "'") | ||||||||||||||||||||||||||||||||||
| .replace(/</g, "<") | ||||||||||||||||||||||||||||||||||
| .replace(/>/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, | ||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Confidence: 5/5 Suggested FixAdd
Suggested change
Setting Prompt for AICopy this prompt to your AI IDE to fix this issue locally: 📍 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(); | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Confidence: 5/5 Suggested Fix
Suggested change
Add response size validation before and after reading the body. Check the Prompt for AICopy this prompt to your AI IDE to fix this issue locally: |
||||||||||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
An attacker could potentially use these ranges to bypass SSRF protection and access internal services.
Confidence: 5/5
Suggested Fix
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:
📍 This suggestion applies to lines 37-48