security: add api key auth middleware and ssrf protection
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user