security: add api key auth middleware and ssrf protection

This commit is contained in:
mstfyldz
2026-05-29 23:26:05 +03:00
parent 0773e7f6f3
commit 5615d0ecab
4 changed files with 155 additions and 2 deletions
+48
View File
@@ -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" }
]
}
```
+34
View File
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from "express";
/**
* API Key authentication middleware.
* Checks for "x-api-key" or "Authorization: Bearer <key>" 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();
};
+14 -2
View File
@@ -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: <API_KEY> (or Authorization: Bearer <API_KEY>)
*
* 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) => {
});
}
});
+59
View File
@@ -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<boolean> {
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;
}
}