From 5615d0ecab23dc151081449058807126fcbef69a Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Fri, 29 May 2026 23:26:05 +0300 Subject: [PATCH] security: add api key auth middleware and ssrf protection --- README.md | 48 +++++++++++++++++++++++++++++++++ src/middlewares/auth.ts | 34 ++++++++++++++++++++++++ src/routes/scrape.ts | 16 +++++++++-- src/utils/ssrf.ts | 59 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/middlewares/auth.ts create mode 100644 src/utils/ssrf.ts diff --git a/README.md b/README.md index 1f73b28..82b5def 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,26 @@ A lightweight API built with Express and Playwright to scrape and extract links from web pages. +## Features + +- **Singleton Browser Pattern:** Fast and memory-efficient. +- **SSRF Protection:** Built-in validation to prevent private and local network access. +- **API Key Security:** Protected endpoints using header authentication. + ## Installation ```bash npm install ``` +## Configuration + +Set the following environment variables (e.g., in a `.env` file or in Coolify settings): + +- `PORT`: (Optional) Port the server will run on (Default: `3001`). +- `API_KEY`: The secret token to authenticate requests. If not set, requests are allowed in development mode but blocked in production. +- `NODE_ENV`: Set to `production` in production mode to enforce strict security. + ## Running the Application ### Development Mode @@ -22,3 +36,37 @@ npm run dev npm run build npm start ``` + +## API Documentation + +### Health Check + +`GET /health` - Checks if the API is running (does not require authentication). + +### Scrape Navigation Links + +`POST /scrape` - Extracts navigation links from the target URL. + +**Headers:** +- `Content-Type: application/json` +- `x-api-key: your_secret_api_key` (Or `Authorization: Bearer your_secret_api_key`) + +**Request Body:** +```json +{ + "url": "https://example.com" +} +``` + +**Response Body:** +```json +{ + "source": "https://example.com", + "count": 12, + "links": [ + { "label": "About Us", "url": "https://example.com/about" }, + { "label": "Contact", "url": "https://example.com/contact" } + ] +} +``` + diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts new file mode 100644 index 0000000..a567a2e --- /dev/null +++ b/src/middlewares/auth.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from "express"; + +/** + * API Key authentication middleware. + * Checks for "x-api-key" or "Authorization: Bearer " headers. + * If API_KEY is not defined in the environment, it allows requests in development + * but blocks them in production to prevent accidental open servers. + */ +export const checkApiKey = (req: Request, res: Response, next: NextFunction) => { + const apiKey = process.env.API_KEY; + + if (!apiKey) { + if (process.env.NODE_ENV === "production") { + console.error("CRITICAL CONFIG ERROR: API_KEY environment variable is not defined in production!"); + return res.status(500).json({ error: "Sunucu yapılandırma hatası." }); + } + // In local dev, allow without API key if not configured, but log a warning + console.warn("WARNING: Running without API_KEY protection. Configure API_KEY in env to secure this endpoint."); + return next(); + } + + // Get token from x-api-key header or Authorization: Bearer token + const clientKey = + req.headers["x-api-key"] || + req.headers["authorization"]?.toString().replace(/^Bearer\s+/i, ""); + + if (!clientKey || clientKey !== apiKey) { + return res.status(401).json({ + error: "Yetkisiz erişim. Geçersiz veya eksik API anahtarı (API Key).", + }); + } + + next(); +}; diff --git a/src/routes/scrape.ts b/src/routes/scrape.ts index 366c903..bb11da5 100644 --- a/src/routes/scrape.ts +++ b/src/routes/scrape.ts @@ -1,11 +1,14 @@ import { Router, Request, Response } from "express"; import { extractNavLinks } from "../services/scraper"; +import { checkApiKey } from "../middlewares/auth"; +import { isSafeUrl } from "../utils/ssrf"; export const scrapeRoute = Router(); /** * POST /scrape * Body: { "url": "https://firma.com" } + * Headers: x-api-key: (or Authorization: Bearer ) * * Response: * { @@ -17,7 +20,7 @@ export const scrapeRoute = Router(); * ] * } */ -scrapeRoute.post("/", async (req: Request, res: Response) => { +scrapeRoute.post("/", checkApiKey, async (req: Request, res: Response) => { const { url } = req.body as { url?: string }; if (!url) { @@ -25,10 +28,18 @@ scrapeRoute.post("/", async (req: Request, res: Response) => { } // Basit URL validasyonu - try { new URL(url); } catch { + try { + new URL(url); + } catch { return res.status(400).json({ error: "Geçersiz URL formatı" }); } + // SSRF Koruması & Yerel Ağ Engellemesi + const safe = await isSafeUrl(url); + if (!safe) { + return res.status(400).json({ error: "Güvensiz veya geçersiz hedef URL." }); + } + try { const links = await extractNavLinks(url); @@ -44,3 +55,4 @@ scrapeRoute.post("/", async (req: Request, res: Response) => { }); } }); + diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts new file mode 100644 index 0000000..3c133e2 --- /dev/null +++ b/src/utils/ssrf.ts @@ -0,0 +1,59 @@ +import dns from "dns"; +import { promisify } from "util"; + +const lookup = promisify(dns.lookup); + +// RegEx patterns for local and private IP address ranges (IPv4 & IPv6) +const PRIVATE_IP_RANGES = [ + /^127\./, // Loopback (127.0.0.0/8) + /^0\./, // Current network (0.0.0.0/8) + /^10\./, // Private Class A (10.0.0.0/8) + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B (172.16.0.0/12) + /^192\.168\./, // Private Class C (192.168.0.0/16) + /^169\.254\./, // Link-local (169.254.0.0/16 - AWS/Cloud metadata) + /^224\./, // Multicast (224.0.0.0/4) + /^240\./, // Reserved (240.0.0.0/4) + /^::1$/, // IPv6 Loopback + /^fc00:/i, // IPv6 Unique Local Addresses + /^fe80:/i, // IPv6 Link-local Addresses +]; + +/** + * Checks if a URL is safe to fetch (prevents SSRF attacks). + * Resolves the domain to an IP address and ensures it is not a local/private address. + */ +export async function isSafeUrl(targetUrl: string): Promise { + try { + const parsed = new URL(targetUrl); + const hostname = parsed.hostname; + + // Only allow HTTP and HTTPS protocols + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + + // Quick match for loopback/local strings + if ( + hostname === "localhost" || + hostname.endsWith(".local") || + hostname === "[::1]" + ) { + return false; + } + + // Resolve DNS to retrieve target IP address + const { address } = await lookup(hostname); + + // Validate the IP address against private ranges + for (const pattern of PRIVATE_IP_RANGES) { + if (pattern.test(address)) { + return false; + } + } + + return true; + } catch { + // If DNS resolution or parsing fails, reject the URL + return false; + } +}