أفضل ممارسات أمان API: قائمة التحقق التي أطبقها في كل مشروع
المصادقة، التفويض، التحقق من المدخلات، تحديد المعدل، CORS، إدارة الأسرار، وقائمة OWASP API Top 10. ما أتحقق منه قبل كل نشر للإنتاج.
لقد أطلقت واجهات برمجية مفتوحة تماماً. ليس بشكل خبيث، وليس بكسل — ببساطة لم أكن أعرف ما لا أعرفه. نقطة نهاية كانت تُرجع كل حقل في كائن المستخدم، بما في ذلك كلمات المرور المُجزّأة. مُحدد معدل كان يتحقق فقط من عناوين IP، مما يعني أن أي شخص خلف بروكسي يمكنه إغراق الواجهة البرمجية. تطبيق JWT نسيت فيه التحقق من ادعاء iss، فكانت الرموز من خدمة مختلفة تماماً تعمل بشكل طبيعي.
كل واحد من تلك الأخطاء وصل إلى الإنتاج. وكل واحد منها تم اكتشافه — بعضها بواسطتي، وبعضها بواسطة المستخدمين، وواحد بواسطة باحث أمني كان لطيفاً بما يكفي ليراسلني عبر البريد الإلكتروني بدلاً من نشره على تويتر.
هذا المقال هو قائمة التحقق التي بنيتها من تلك الأخطاء. أمر عليها قبل كل نشر للإنتاج. ليس لأنني مصاب بجنون الارتياب، بل لأنني تعلمت أن أخطاء الأمان هي التي تؤلم أكثر. زر معطل يزعج المستخدمين. تدفق مصادقة معطل يُسرّب بياناتهم.
المصادقة مقابل التفويض#
هاتان الكلمتان تُستخدمان بالتبادل في الاجتماعات والوثائق وحتى في تعليقات الكود. لكنهما ليستا نفس الشيء.
المصادقة (Authentication) تجيب على: "من أنت؟" إنها خطوة تسجيل الدخول. اسم المستخدم وكلمة المرور، تدفق OAuth، الرابط السحري — أي شيء يثبت هويتك.
التفويض (Authorization) يجيب على: "ما المسموح لك بفعله؟" إنها خطوة الصلاحيات. هل يمكن لهذا المستخدم حذف هذا المورد؟ هل يمكنه الوصول إلى نقطة نهاية المدير؟ هل يمكنه قراءة بيانات مستخدم آخر؟
أكثر خطأ أمني شائع رأيته في واجهات الإنتاج ليس تدفق تسجيل دخول معطل. إنه فحص تفويض مفقود. المستخدم مُصادَق — لديه رمز صالح — لكن الواجهة البرمجية لا تتحقق أبداً مما إذا كان مسموحاً له بتنفيذ الإجراء الذي يطلبه.
JWT: البنية والأخطاء المهمة#
رموز JWT موجودة في كل مكان. وهي أيضاً يُساء فهمها في كل مكان. يتكون JWT من ثلاثة أجزاء، مفصولة بنقاط:
header.payload.signature
الرأس يحدد الخوارزمية المستخدمة. الحمولة تحتوي على الادعاءات (معرف المستخدم، الأدوار، تاريخ الانتهاء). التوقيع يثبت أن لا أحد عبث بالجزأين الأولين.
إليك عملية تحقق صحيحة من JWT في Node.js:
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
interface TokenPayload {
sub: string;
role: "user" | "admin";
iss: string;
aud: string;
exp: number;
iat: number;
jti: string;
}
function verifyToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"], // لا تسمح أبداً بـ "none"
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // 30 ثانية سماح لفرق الساعات
}) as TokenPayload;
return payload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(401, "Token expired");
}
if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError(401, "Invalid token");
}
throw new ApiError(401, "Authentication failed");
}
}بعض الملاحظات المهمة:
-
algorithms: ["HS256"]— هذا أمر حاسم. إذا لم تحدد الخوارزمية، يمكن للمهاجم إرسال رمز بـ"alg": "none"في الرأس وتخطي التحقق بالكامل. هذا هو هجومalg: none، وقد أثر على أنظمة إنتاجية حقيقية. -
issuerوaudience— بدونهما، يعمل رمز صادر من الخدمة أ على الخدمة ب. إذا كنت تشغل خدمات متعددة تتشارك نفس السر (وهو ما لا يجب فعله، لكن الناس يفعلونه)، فهكذا يحدث إساءة استخدام الرموز عبر الخدمات. -
معالجة أخطاء محددة — لا تُرجع "رمز غير صالح" لكل فشل. التمييز بين منتهي الصلاحية وغير الصالح يساعد العميل على معرفة ما إذا كان يجب تجديد الرمز أو إعادة المصادقة.
تدوير رمز التحديث#
يجب أن تكون رموز الوصول قصيرة العمر — 15 دقيقة هو المعيار. لكنك لا تريد من المستخدمين إعادة إدخال كلمة المرور كل 15 دقيقة. هنا يأتي دور رموز التحديث.
النمط الذي يعمل فعلياً في الإنتاج:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // عائلة الرمز لكشف التدوير
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// الرمز غير موجود — إما منتهي الصلاحية أو مستخدم مسبقاً.
// إذا كان مستخدماً مسبقاً، فهذا هجوم إعادة تشغيل محتمل.
// إبطال عائلة الرموز بالكامل.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// حذف الرمز القديم فوراً — استخدام واحد فقط
await redis.del(`refresh:${oldRefreshToken}`);
// توليد رموز جديدة
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// تخزين رمز التحديث الجديد بنفس العائلة
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 يوماً
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// البحث عن جميع الرموز في هذه العائلة وحذفها.
// هذا هو الخيار النووي — إذا أعاد شخص ما تشغيل رمز تحديث،
// نقتل كل رمز في العائلة، مما يفرض إعادة المصادقة.
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const parsed = JSON.parse(data) as RefreshTokenData;
if (parsed.family === family) {
await redis.del(key);
}
}
}
}مفهوم عائلة الرمز هو ما يجعل هذا آمناً. كل رمز تحديث ينتمي إلى عائلة (تُنشأ عند تسجيل الدخول). عند التدوير، يرث الرمز الجديد العائلة. إذا أعاد مهاجم تشغيل رمز تحديث قديم، تكتشف إعادة الاستخدام وتقتل العائلة بأكملها. المستخدم الشرعي يتم تسجيل خروجه، لكن المهاجم لا يدخل.
تخزين الرموز: نقاش كوكيز httpOnly مقابل localStorage#
هذا النقاش مستمر منذ سنوات، والجواب واضح: كوكيز httpOnly لرموز التحديث، دائماً.
localStorage متاح لأي JavaScript يعمل على صفحتك. إذا كان لديك ثغرة XSS واحدة — وعلى نطاق واسع، ستكون لديك في النهاية — يمكن للمهاجم قراءة الرمز وتسريبه. انتهت اللعبة.
كوكيز httpOnly غير متاحة لـ JavaScript. نقطة. ثغرة XSS يمكنها لا تزال إجراء طلبات نيابة عن المستخدم (لأن الكوكيز تُرسل تلقائياً)، لكن المهاجم لا يمكنه سرقة الرمز نفسه. هذا فرق مهم.
// تعيين كوكي رمز تحديث آمن
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // غير متاح عبر JavaScript
secure: true, // HTTPS فقط
sameSite: "strict", // لا طلبات عبر المواقع
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 يوماً
path: "/api/auth", // يُرسل فقط لنقاط نهاية المصادقة
});
}path: "/api/auth" هو تفصيل يفوته معظم الناس. بشكل افتراضي، تُرسل الكوكيز إلى كل نقطة نهاية على نطاقك. رمز التحديث لا يحتاج للذهاب إلى /api/users أو /api/products. قيّد المسار، قلّل سطح الهجوم.
لرموز الوصول، أحتفظ بها في الذاكرة (متغير JavaScript). ليس localStorage، ليس sessionStorage، ليس كوكي. في الذاكرة. عمرها قصير (15 دقيقة)، وعندما تُحدث الصفحة، يضرب العميل بصمت نقطة نهاية التحديث للحصول على رمز جديد. نعم، هذا يعني طلباً إضافياً عند تحميل الصفحة. الأمر يستحق ذلك.
التحقق من المدخلات: لا تثق أبداً بالعميل#
العميل ليس صديقك. العميل غريب دخل منزلك وقال "مسموح لي أن أكون هنا." أنت تتحقق من هويته على أي حال.
كل جزء من البيانات يأتي من خارج خادمك — جسم الطلب، معاملات الاستعلام، معاملات URL، الرؤوس — هي مدخلات غير موثوقة. لا يهم أن نموذج React لديك يحتوي على تحقق. شخص ما سيتجاوزه باستخدام curl.
Zod للتحقق الآمن من الأنواع#
Zod هو أفضل شيء حدث للتحقق من المدخلات في Node.js. يمنحك تحققاً في وقت التشغيل مع أنواع TypeScript مجاناً:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("Invalid email format")
.max(254, "Email too long")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "Password must be at least 12 characters")
.max(128, "Password too long") // منع هجوم DoS عبر bcrypt
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and a number"
),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
role: z.enum(["user", "editor"]).default("user"),
// ملاحظة: "admin" متروك عمداً هنا.
// تعيين دور المدير يتم عبر نقطة نهاية منفصلة ذات صلاحيات.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// الاستخدام في معالج Express
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.data مُحدد النوع بالكامل كـ CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});بعض التفاصيل المتعلقة بالأمان:
max(128)على كلمة المرور — bcrypt له حد إدخال 72 بايت، وبعض التطبيقات تقطع بصمت. لكن الأهم، إذا سمحت بكلمة مرور 10 ميغابايت، سيقضي bcrypt وقتاً كبيراً في تجزئتها. هذا ناقل DoS.max(254)على البريد الإلكتروني — RFC 5321 يحد عناوين البريد بـ 254 حرفاً. أي شيء أطول ليس بريداً صالحاً.- Enum للدور، بدون admin — التعيين الجماعي هو من أقدم ثغرات API. إذا قبلت الدور من جسم الطلب دون التحقق، سيرسل شخص ما
"role": "admin"ويأمل في الأفضل.
حقن SQL لم يُحل بعد#
"استخدم ORM فحسب" لا يحميك إذا كتبت استعلامات خام للأداء. والجميع يكتب استعلامات خام للأداء في النهاية.
// ثغرة — ربط سلاسل نصية
const query = `SELECT * FROM users WHERE email = '${email}'`;
// آمن — استعلام معلمي
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);مع Prisma، أنت آمن في الغالب — لكن $queryRaw يمكن أن يخذلك:
// ثغرة — قالب حرفي في $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// آمن — استخدام Prisma.sql للمعلمات
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);حقن NoSQL#
MongoDB لا يستخدم SQL، لكنه ليس محصناً ضد الحقن. إذا مررت مدخلات مستخدم غير منظفة ككائن استعلام، تسوء الأمور:
// ثغرة — إذا كان req.body.username هو { "$gt": "" }
// يُرجع أول مستخدم في المجموعة
const user = await db.collection("users").findOne({
username: req.body.username,
});
// آمن — تحويل صريح إلى سلسلة نصية
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// أفضل — التحقق بـ Zod أولاً
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});الحل بسيط: تحقق من أنواع المدخلات قبل وصولها إلى محرك قاعدة البيانات. إذا كان username يجب أن يكون سلسلة نصية، تأكد أنه سلسلة نصية.
اجتياز المسار#
إذا كانت واجهتك البرمجية تقدم ملفات أو تقرأ من مسار يتضمن مدخلات المستخدم، فإن اجتياز المسار سيفسد أسبوعك:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// تطبيع وتحويل إلى مسار مطلق
const resolved = path.resolve(ALLOWED_DIR, userInput);
// حاسم: التحقق من أن المسار المحلول لا يزال داخل المجلد المسموح
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// التحقق من وجود الملف فعلياً
await access(resolved, constants.R_OK);
return resolved;
}
// بدون هذا الفحص:
// GET /api/files?name=../../../etc/passwd
// يُحل إلى /etc/passwdنمط path.resolve + startsWith هو النهج الصحيح. لا تحاول إزالة ../ يدوياً — هناك الكثير من حيل الترميز (..%2F، ..%252F، ....//) التي ستتجاوز regex الخاص بك.
تحديد المعدل#
بدون تحديد المعدل، واجهتك البرمجية هي بوفيه مفتوح للروبوتات. هجمات القوة الغاشمة، حشو بيانات الاعتماد، استنفاد الموارد — تحديد المعدل هو الدفاع الأول ضد كل ذلك.
دلو الرموز مقابل النافذة المنزلقة#
دلو الرموز (Token bucket): لديك دلو يحمل N رمزاً. كل طلب يكلف رمزاً واحداً. تُعاد تعبئة الرموز بمعدل ثابت. إذا كان الدلو فارغاً، يُرفض الطلب. هذا يسمح بالانفجارات — إذا كان الدلو ممتلئاً، يمكنك إجراء N طلباً فوراً.
النافذة المنزلقة (Sliding window): عد الطلبات ضمن نافذة زمنية متحركة. أكثر قابلية للتنبؤ، أصعب للاختراق بالانفجارات.
أستخدم النافذة المنزلقة لمعظم الأشياء لأن السلوك أسهل في الفهم والشرح للفريق:
import { Redis } from "ioredis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function slidingWindowRateLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
// إزالة المدخلات خارج النافذة
multi.zremrangebyscore(key, 0, windowStart);
// عد المدخلات في النافذة
multi.zcard(key);
// إضافة الطلب الحالي (سنزيله إذا تجاوز الحد)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// تعيين انتهاء الصلاحية على المفتاح
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const count = results[1][1] as number;
if (count >= limit) {
// تجاوز الحد — إزالة المدخل الذي أضفناه للتو
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}حدود المعدل المتعددة الطبقات#
حد معدل عالمي واحد ليس كافياً. نقاط النهاية المختلفة لها مستويات مخاطر مختلفة:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// نقاط نهاية المصادقة — حدود ضيقة، هدف القوة الغاشمة
"POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
"POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
"POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
// قراءة البيانات — أكثر سخاء
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// كتابة البيانات — معتدل
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// الاحتياطي العالمي
"*": { window: 60 * 1000, max: 60 },
};
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
const identifier = req.user?.id ?? getClientIp(req);
const endpoint = `${req.method}:${req.path}`;
return `ratelimit:${identifier}:${endpoint}`;
}لاحظ: المستخدمون المُصادَقون يُحدد معدلهم بمعرف المستخدم، وليس IP. هذا مهم لأن العديد من المستخدمين الشرعيين يتشاركون عناوين IP (شبكات الشركات، VPN، شركات الاتصالات المحمولة). إذا حددت فقط بـ IP، ستحظر مكاتب بأكملها.
رؤوس تحديد المعدل#
أخبر العميل دائماً بما يحدث:
function setRateLimitHeaders(
res: Response,
result: RateLimitResult,
limit: number
): void {
res.set({
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": result.remaining.toString(),
"X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
"Retry-After": result.allowed
? undefined
: Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
});
if (!result.allowed) {
res.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
});
}
}تكوين CORS#
CORS هي على الأرجح أكثر آلية أمان يُساء فهمها في تطوير الويب. نصف إجابات Stack Overflow حول CORS هي "فقط عيّن Access-Control-Allow-Origin: * وسيعمل." هذا صحيح تقنياً. وهو أيضاً كيف تفتح واجهتك البرمجية لكل موقع خبيث على الإنترنت.
ما تفعله CORS فعلاً (وما لا تفعله)#
CORS هي آلية متصفح. تخبر المتصفح ما إذا كان JavaScript من الأصل أ مسموحاً له بقراءة الاستجابة من الأصل ب. هذا كل شيء.
ما لا تفعله CORS:
- لا تحمي واجهتك البرمجية من curl أو Postman أو الطلبات من خادم لخادم
- لا تُصادق الطلبات
- لا تُشفر أي شيء
- لا تمنع CSRF بمفردها (رغم أنها تساعد مع آليات أخرى)
ما تفعله CORS:
- تمنع malicious-website.com من إجراء طلبات fetch إلى your-api.com وقراءة الاستجابة في متصفح المستخدم
- تمنع JavaScript المهاجم من تسريب البيانات عبر جلسة الضحية المُصادَقة
فخ حرف البدل#
// خطير — يسمح لأي موقع بقراءة استجابات واجهتك البرمجية
app.use(cors({ origin: "*" }));
// خطير أيضاً — هذا نهج "ديناميكي" شائع وهو مجرد * بخطوات إضافية
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // يسمح بكل شيء
},
})
);مشكلة * هي أنها تجعل استجابات واجهتك البرمجية قابلة للقراءة من أي JavaScript على أي صفحة. إذا كانت واجهتك تُرجع بيانات المستخدم والمستخدم مُصادَق عبر الكوكيز، يمكن لأي موقع يزوره المستخدم قراءة تلك البيانات.
والأسوأ: Access-Control-Allow-Origin: * لا يمكن دمجها مع credentials: true. لذا إذا كنت تحتاج الكوكيز (للمصادقة)، لا يمكنك حرفياً استخدام حرف البدل. لكنني رأيت أشخاصاً يحاولون التغلب على هذا بعكس رأس Origin — وهو ما يعادل * مع بيانات الاعتماد، أسوأ الحالتين.
التكوين الصحيح#
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://yourapp.com",
"https://www.yourapp.com",
"https://admin.yourapp.com",
]);
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
app.use(
cors({
origin: (origin, callback) => {
// السماح بالطلبات بدون أصل (تطبيقات الموبايل، curl، خادم لخادم)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // السماح بالكوكيز
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // تخزين preflight لمدة 24 ساعة
})
);القرارات الرئيسية:
- مجموعة أصول صريحة، وليس regex. التعبيرات النظامية خادعة —
yourapp.comقد يطابقevilyourapp.comإذا لم تكن regex مثبتة بشكل صحيح. credentials: trueلأننا نستخدم كوكيز httpOnly لرموز التحديث.maxAge: 86400— طلبات Preflight (OPTIONS) تضيف تأخيراً. إخبار المتصفح بتخزين نتيجة CORS لمدة 24 ساعة يقلل الرحلات غير الضرورية.exposedHeaders— بشكل افتراضي، يكشف المتصفح فقط حفنة من رؤوس الاستجابة "البسيطة" لـ JavaScript. إذا أردت أن يقرأ العميل رؤوس تحديد المعدل، يجب أن تكشفها صريحاً.
طلبات Preflight#
عندما لا يكون الطلب "بسيطاً" (يستخدم رأساً غير قياسي، أو طريقة غير قياسية، أو نوع محتوى غير قياسي)، يرسل المتصفح طلب OPTIONS أولاً لطلب الإذن. هذا هو preflight.
إذا لم يتعامل تكوين CORS مع OPTIONS، ستفشل طلبات preflight، ولن يُرسل الطلب الفعلي أبداً. معظم مكتبات CORS تتعامل مع هذا تلقائياً، لكن إذا كنت تستخدم إطار عمل لا يفعل ذلك، عليك التعامل معه:
// معالجة preflight يدوياً (معظم الأطر تفعل هذا لك)
app.options("*", (req, res) => {
res.set({
"Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
});
res.status(204).end();
});رؤوس الأمان#
رؤوس الأمان هي أرخص تحسين أمني يمكنك القيام به. إنها رؤوس استجابة تخبر المتصفح بتفعيل ميزات الأمان. معظمها سطر واحد من التكوين، وتحمي ضد فئات كاملة من الهجمات.
الرؤوس المهمة#
import helmet from "helmet";
// سطر واحد. هذا أسرع مكسب أمني في أي تطبيق Express.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // مطلوب للعديد من حلول CSS-in-JS
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // سنة واحدة
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);ما يفعله كل رأس:
Content-Security-Policy (CSP) — أقوى رأس أمان. يخبر المتصفح بالضبط أي المصادر مسموح بها للنصوص البرمجية والأنماط والصور والخطوط إلخ. إذا حقن مهاجم وسم <script> يُحمّل من evil.com، يحظره CSP. هذا هو أكثر دفاع فعال ضد XSS.
Strict-Transport-Security (HSTS) — يخبر المتصفح باستخدام HTTPS دائماً، حتى لو كتب المستخدم http://. توجيه preload يتيح لك تقديم نطاقك لقائمة HSTS المدمجة في المتصفح، فحتى الطلب الأول يُجبر على HTTPS.
X-Frame-Options — يمنع تضمين موقعك في iframe. هذا يوقف هجمات clickjacking حيث يغطي المهاجم صفحتك بعناصر غير مرئية. Helmet يعينه على SAMEORIGIN بشكل افتراضي. البديل الحديث هو frame-ancestors في CSP.
X-Content-Type-Options: nosniff — يمنع المتصفح من تخمين (استشعار) نوع MIME للاستجابة. بدون هذا، إذا قدمت ملفاً بنوع Content-Type خاطئ، قد ينفذه المتصفح كـ JavaScript.
Referrer-Policy — يتحكم في مقدار معلومات URL المُرسلة في رأس Referer. strict-origin-when-cross-origin يرسل URL الكامل لطلبات نفس الأصل لكن فقط الأصل للطلبات عبر الأصول. هذا يمنع تسريب معاملات URL الحساسة لأطراف ثالثة.
اختبار رؤوسك#
بعد النشر، تحقق من درجتك في securityheaders.com. استهدف تقييم A+. يستغرق الأمر حوالي خمس دقائق من التكوين للوصول إليه.
يمكنك أيضاً التحقق من الرؤوس برمجياً:
import { describe, it, expect } from "vitest";
describe("Security headers", () => {
it("should include all required security headers", async () => {
const response = await fetch("https://api.yourapp.com/health");
expect(response.headers.get("strict-transport-security")).toBeTruthy();
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(response.headers.get("content-security-policy")).toBeTruthy();
expect(response.headers.get("referrer-policy")).toBeTruthy();
expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet يزيل هذا
});
});فحص x-powered-by دقيق لكنه مهم. Express يعين X-Powered-By: Express بشكل افتراضي، مما يخبر المهاجمين بالضبط أي إطار تستخدمه. Helmet يزيله.
إدارة الأسرار#
هذا يجب أن يكون واضحاً، لكنني لا أزال أراه في طلبات السحب: مفاتيح API، كلمات مرور قاعدة البيانات، وأسرار JWT مكتوبة بشكل ثابت في ملفات المصدر. أو مُلتزم بها في ملفات .env التي لم تكن في .gitignore. بمجرد أن تكون في تاريخ git، فهي هناك للأبد، حتى لو حذفت الملف في الالتزام التالي.
القواعد#
-
لا تُلزم الأسرار في git أبداً. ليس في الكود، ليس في
.env، ليس في ملفات التكوين، ليس في ملفات Docker Compose، ليس في تعليقات "فقط للاختبار". -
استخدم
.env.exampleكقالب. يوثق متغيرات البيئة المطلوبة، دون احتواء قيم فعلية:
# .env.example — التزم بهذا
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
# .env — لا تُلزم هذا أبداً
# مُدرج في .gitignore- تحقق من متغيرات البيئة عند البدء. لا تنتظر حتى يصل طلب إلى نقطة نهاية تحتاج رابط قاعدة البيانات. افشل بسرعة:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.format());
process.exit(1); // لا تبدأ بتكوين سيئ
}
return result.data;
}
export const env = validateEnv();- استخدم مدير أسرار في الإنتاج. متغيرات البيئة تعمل للإعدادات البسيطة، لكن لها قيود: مرئية في قوائم العمليات، تبقى في الذاكرة، ويمكن أن تتسرب عبر سجلات الأخطاء.
لأنظمة الإنتاج، استخدم مدير أسرار حقيقي:
- AWS Secrets Manager أو SSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler (إذا أردت شيئاً يعمل عبر جميع السحب)
النمط هو نفسه بغض النظر عن أي واحد تستخدمه: التطبيق يجلب الأسرار عند البدء من مدير الأسرار، وليس من متغيرات البيئة.
- دوّر الأسرار بانتظام. إذا كنت تستخدم نفس سر JWT لمدة عامين، حان وقت التدوير. نفّذ تدوير المفاتيح: ادعم عدة مفاتيح توقيع صالحة في نفس الوقت، وقّع الرموز الجديدة بالمفتاح الجديد، تحقق بكلا المفتاحين القديم والجديد، وتقاعد المفتاح القديم بعد انتهاء صلاحية جميع الرموز الحالية.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // فقط المفتاح النشط يوقع الرموز الجديدة
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // يُرجع جميع المفاتيح الصالحة
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // جرب المفتاح التالي
}
}
throw new ApiError(401, "Invalid token");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // تضمين معرف المفتاح في الرأس
});
}OWASP API Security Top 10#
قائمة OWASP API Security Top 10 هي المعيار الصناعي لثغرات API. تُحدّث دورياً، وكل عنصر في القائمة هو شيء رأيته في قواعد كود حقيقية. دعني أمر على كل واحد.
API1: خلل في التفويض على مستوى الكائن (BOLA)#
أكثر ثغرة API شيوعاً. المستخدم مُصادَق، لكن الواجهة لا تتحقق مما إذا كان لديه وصول إلى الكائن المحدد الذي يطلبه.
// ثغرة — أي مستخدم مُصادَق يمكنه الوصول لبيانات أي مستخدم
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// مُصلح — التحقق من أن المستخدم يصل لبياناته (أو أنه مدير)
app.get("/api/users/:id", authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});النسخة الضعيفة موجودة في كل مكان. تجتاز كل فحص مصادقة — المستخدم لديه رمز صالح — لكنها لا تتحقق من أنه مُصرَّح له بالوصول إلى هذا المورد بالتحديد. غيّر المعرف في الرابط، وتحصل على بيانات شخص آخر.
API2: خلل في المصادقة#
آليات تسجيل دخول ضعيفة، MFA مفقود، رموز لا تنتهي أبداً، كلمات مرور مخزنة بنص عادي. هذا يغطي طبقة المصادقة نفسها.
الحل هو كل ما ناقشناه في قسم المصادقة: متطلبات كلمة مرور قوية، bcrypt بعدد جولات كافٍ، رموز وصول قصيرة العمر، تدوير رموز التحديث، قفل الحساب بعد محاولات فاشلة.
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 دقيقة
async function handleLogin(email: string, password: string): Promise<AuthResult> {
const lockoutKey = `lockout:${email}`;
const attempts = await redis.get(lockoutKey);
if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
const ttl = await redis.pttl(lockoutKey);
throw new ApiError(
429,
`Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// زيادة المحاولات الفاشلة
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// نفس رسالة الخطأ للحالتين — لا تكشف ما إذا كان البريد موجوداً
throw new ApiError(401, "Invalid email or password");
}
// إعادة تعيين المحاولات الفاشلة عند تسجيل الدخول الناجح
await redis.del(lockoutKey);
return generateTokens(user);
}التعليق حول "نفس رسالة الخطأ" مهم. إذا أرجعت واجهتك "مستخدم غير موجود" للبريد غير الصالح و"كلمة مرور خاطئة" للبريد الصالح مع كلمة مرور خاطئة، فأنت تخبر المهاجم أي العناوين موجودة في نظامك.
API3: خلل في التفويض على مستوى خاصية الكائن#
إرجاع بيانات أكثر من اللازم، أو السماح للمستخدمين بتعديل خصائص لا يجب عليهم ذلك.
// ثغرة — يُرجع كائن المستخدم بالكامل، بما في ذلك الحقول الداخلية
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// الاستجابة تتضمن: passwordHash, internalNotes, billingId, ...
});
// مُصلح — قائمة سماح صريحة للحقول المُرجعة
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
});
});لا تُرجع أبداً كائنات قاعدة البيانات بالكامل. اختر دائماً الحقول التي تريد كشفها. هذا ينطبق على الكتابة أيضاً — لا تنشر جسم الطلب بالكامل في استعلام التحديث:
// ثغرة — التعيين الجماعي
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// المهاجم يرسل: { "role": "admin", "verified": true }
});
// مُصلح — اختيار الحقول المسموحة
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
avatar: z.string().url().optional(),
});
app.put("/api/users/:id", authenticate, async (req, res) => {
const data = UpdateUserSchema.parse(req.body);
await db.users.update(req.params.id, data);
});API4: استهلاك غير مقيد للموارد#
واجهتك البرمجية هي مورد. المعالج والذاكرة وعرض النطاق واتصالات قاعدة البيانات — كلها محدودة. بدون قيود، يمكن لعميل واحد استنفادها جميعاً.
هذا يتجاوز تحديد المعدل. يشمل:
// تحديد حجم جسم الطلب
app.use(express.json({ limit: "1mb" }));
// تحديد تعقيد الاستعلام
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_PAGE_SIZE)
.default(DEFAULT_PAGE_SIZE),
});
// تحديد حجم رفع الملفات
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5 ميغابايت
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type"));
}
},
});
// مهلة الطلبات طويلة التشغيل
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});API5: خلل في التفويض على مستوى الوظيفة#
مختلف عن BOLA. هذا يتعلق بالوصول إلى وظائف (نقاط نهاية) لا يجب أن تصل إليها، وليس الكائنات. المثال الكلاسيكي: مستخدم عادي يكتشف نقاط نهاية المدير.
// وسيط يتحقق من الوصول المبني على الأدوار
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
// سجّل المحاولة — قد يكون هذا هجوماً
logger.warn("Unauthorized access attempt", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// التطبيق على المسارات
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);لا تعتمد على إخفاء نقاط النهاية. "الأمان من خلال الغموض" ليس أماناً. حتى لو لم يكن رابط لوحة المدير مربوطاً في أي مكان، سيجد شخص ما /api/admin/users بالتخمين.
API6: الوصول غير المقيد لتدفقات الأعمال الحساسة#
إساءة استخدام آلية لوظائف أعمال مشروعة. فكر في: روبوتات تشتري سلعاً محدودة المخزون، إنشاء حسابات آلي للبريد المزعج، جمع أسعار المنتجات.
التخفيفات خاصة بالسياق: CAPTCHA، بصمة الجهاز، التحليل السلوكي، مصادقة متقدمة للعمليات الحساسة. لا يوجد مقطع كود واحد يناسب الجميع.
API7: تزوير الطلبات من جانب الخادم (SSRF)#
إذا كانت واجهتك تجلب روابط يقدمها المستخدم (webhooks، روابط صور الملف الشخصي، معاينات الروابط)، يمكن للمهاجم جعل خادمك يطلب موارد داخلية:
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
async function safeFetch(userProvidedUrl: string): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(userProvidedUrl);
} catch {
throw new ApiError(400, "Invalid URL");
}
// السماح فقط بـ HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// حل اسم المضيف والتحقق مما إذا كان IP خاصاً
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Internal addresses are not allowed");
}
}
// الآن اجلب مع مهلة وحد حجم
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // لا تتبع إعادات التوجيه (قد تُعيد التوجيه إلى عناوين IP داخلية)
});
return response;
} finally {
clearTimeout(timeout);
}
}التفاصيل الرئيسية: حل DNS أولاً وتحقق من IP قبل إجراء الطلب. احظر إعادات التوجيه — يمكن للمهاجم استضافة رابط يُعيد التوجيه إلى http://169.254.169.254/ (نقطة نهاية بيانات AWS الوصفية) لتجاوز فحصك على مستوى الرابط.
API8: سوء تكوين الأمان#
بيانات اعتماد افتراضية لم تُغيّر، طرق HTTP غير ضرورية مفعّلة، رسائل خطأ مطولة في الإنتاج، قائمة المجلدات مفعّلة، CORS سيئ التكوين. هذه فئة "نسيت قفل الباب".
// لا تسرّب تتبع المكدس في الإنتاج
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("Unhandled error", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// رسالة خطأ عامة — لا تكشف الداخليات
res.status(500).json({
error: "Internal server error",
requestId: req.id, // تضمين معرف طلب للتصحيح
});
} else {
// في التطوير، اعرض الخطأ الكامل
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// تعطيل طرق HTTP غير الضرورية
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Method not allowed" });
}
next();
});API9: سوء إدارة المخزون#
نشرت الإصدار 2 من الواجهة لكن نسيت إيقاف الإصدار 1. أو هناك نقطة نهاية /debug/ كانت مفيدة أثناء التطوير ولا تزال تعمل في الإنتاج. أو خادم تجريبي متاح للعامة ببيانات إنتاج.
هذا ليس إصلاح كود — إنه انضباط عمليات. احتفظ بقائمة بجميع نقاط نهاية API، وجميع الإصدارات المنشورة، وجميع البيئات. استخدم الفحص الآلي للعثور على الخدمات المكشوفة. اقتل ما لا تحتاجه.
API10: استهلاك غير آمن لواجهات API#
واجهتك تستهلك واجهات طرف ثالث. هل تتحقق من استجاباتها؟ ماذا يحدث إذا كانت حمولة webhook من Stripe في الواقع من مهاجم؟
import crypto from "crypto";
// التحقق من توقيعات webhook الخاصة بـ Stripe
function verifyStripeWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
if (!timestamp || !expectedSig) return false;
// رفض الطوابع الزمنية القديمة (منع هجمات الإعادة)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // تسامح 5 دقائق
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}تحقق دائماً من التوقيعات على webhooks. تحقق دائماً من بنية استجابات واجهات الطرف الثالث. عيّن دائماً مهلات على الطلبات الصادرة. لا تثق أبداً ببيانات لمجرد أنها جاءت من "شريك موثوق."
تسجيل المراجعة#
عندما يحدث خطأ ما — وسيحدث — سجلات المراجعة هي كيف تكتشف ما حدث. لكن التسجيل سيف ذو حدين. سجّل القليل جداً وأنت أعمى. سجّل الكثير جداً وتُنشئ مسؤولية خصوصية.
ما يجب تسجيله#
interface AuditLogEntry {
timestamp: string;
action: string; // "user.login", "post.delete", "admin.role_change"
actor: {
id: string;
ip: string;
userAgent: string;
};
target: {
type: string; // "user", "post", "setting"
id: string;
};
result: "success" | "failure";
metadata: Record<string, unknown>; // سياق إضافي
requestId: string; // للربط مع سجلات التطبيق
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// الكتابة إلى مخزن بيانات منفصل، للإلحاق فقط
// هذا يجب ألا يكون نفس قاعدة بيانات تطبيقك
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// للإجراءات الحرجة، اكتب أيضاً إلى سجل خارجي غير قابل للتغيير
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}سجّل هذه الأحداث:
- المصادقة: تسجيلات الدخول والخروج، المحاولات الفاشلة، تحديث الرموز
- التفويض: أحداث رفض الوصول (غالباً مؤشرات هجوم)
- تعديل البيانات: الإنشاء والتحديث والحذف — من غيّر ماذا، ومتى
- إجراءات المدير: تغيير الأدوار، إدارة المستخدمين، تغيير التكوين
- أحداث الأمان: تفعيل حد المعدل، انتهاكات CORS، الطلبات المشوهة
ما لا يجب تسجيله#
لا تسجّل أبداً:
- كلمات المرور (حتى المُجزّأة — التجزئة هي بيانات اعتماد)
- أرقام بطاقات الائتمان الكاملة (سجّل آخر 4 أرقام فقط)
- أرقام الضمان الاجتماعي أو الهويات الحكومية
- مفاتيح API أو الرموز (سجّل بادئة على الأكثر:
sk_live_...abc) - معلومات الصحة الشخصية
- أجسام الطلبات الكاملة التي قد تحتوي على PII
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = new Set([
"password",
"passwordHash",
"token",
"secret",
"apiKey",
"creditCard",
"ssn",
"authorization",
]);
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.has(key.toLowerCase())) {
sanitized[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
} else {
sanitized[key] = value;
}
}
return sanitized;
}سجلات مقاومة للتلاعب#
إذا تمكن مهاجم من الوصول إلى نظامك، فإن أول شيء سيفعله هو تعديل السجلات لإخفاء آثاره. التسجيل المقاوم للتلاعب يجعل هذا قابلاً للكشف:
import crypto from "crypto";
let previousHash = "GENESIS"; // التجزئة الأولية في السلسلة
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
const content = JSON.stringify(entry) + previousHash;
const hash = crypto.createHash("sha256").update(content).digest("hex");
previousHash = hash;
return { ...entry, hash };
}
// للتحقق من سلامة السلسلة:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
let expectedPreviousHash = "GENESIS";
for (const entry of entries) {
const { hash, ...rest } = entry;
const content = JSON.stringify(rest) + expectedPreviousHash;
const computedHash = crypto.createHash("sha256").update(content).digest("hex");
if (computedHash !== hash) {
return false; // السلسلة مكسورة — تم التلاعب بالسجلات
}
expectedPreviousHash = hash;
}
return true;
}هذا نفس مفهوم البلوكتشين — تجزئة كل إدخال سجل تعتمد على الإدخال السابق. إذا عدّل شخص ما أو حذف إدخالاً، تنكسر السلسلة.
أمان التبعيات#
قد يكون كودك آمناً. لكن ماذا عن 847 حزمة npm في node_modules؟ مشكلة سلسلة التوريد حقيقية، وقد ساءت على مر السنين.
npm audit هو الحد الأدنى#
# شغّل هذا في CI، أفشل البناء على الثغرات العالية/الحرجة
npm audit --audit-level=high
# أصلح ما يمكن إصلاحه تلقائياً
npm audit fix
# انظر ما تسحبه فعلاً
npm ls --allلكن npm audit له قيود. يتحقق فقط من قاعدة بيانات npm الاستشارية، وتصنيفات خطورته ليست دقيقة دائماً. أضف أدوات إضافية:
فحص التبعيات الآلي#
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# تجميع التحديثات الثانوية والتصحيحية لتقليل ضوضاء PR
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"ملف القفل أداة أمان#
دائماً التزم بـ package-lock.json (أو pnpm-lock.yaml، أو yarn.lock). ملف القفل يثبت الإصدارات الدقيقة لكل تبعية، بما في ذلك التبعيات العابرة. بدونه، npm install قد يسحب إصداراً مختلفاً عما اختبرته — وذلك الإصدار المختلف قد يكون مُخترقاً.
# في CI، استخدم ci بدلاً من install — يحترم ملف القفل بصرامة
npm cinpm ci يفشل إذا لم يتطابق ملف القفل مع package.json، بدلاً من تحديثه بصمت. هذا يلتقط الحالات التي عدّل فيها شخص ما package.json لكن نسي تحديث ملف القفل.
قيّم قبل التثبيت#
قبل إضافة تبعية، اسأل:
- هل أحتاج هذا فعلاً؟ هل يمكنني كتابة هذا في 20 سطراً بدلاً من إضافة حزمة؟
- كم عدد التنزيلات؟ أعداد تنزيل منخفضة ليست بالضرورة سيئة، لكنها تعني عيوناً أقل تراجع الكود.
- متى آخر تحديث؟ حزمة لم تُحدّث منذ 3 سنوات قد تحتوي على ثغرات غير مُصححة.
- كم تبعية تسحب؟
is-oddيعتمد علىis-numberالذي يعتمد علىkind-of. هذه ثلاث حزم لفعل شيء يمكن لسطر واحد من الكود فعله. - من يصونها؟ مُصين واحد هو نقطة اختراق واحدة.
// لا تحتاج حزمة لهذا:
const isEven = (n: number): boolean => n % 2 === 0;
// أو هذا:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// أو هذا:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;قائمة التحقق قبل النشر#
هذه هي قائمة التحقق الفعلية التي أستخدمها قبل كل نشر إنتاج. ليست شاملة — الأمان لا "يكتمل" أبداً — لكنها تلتقط الأخطاء الأكثر أهمية.
| # | الفحص | معايير النجاح | الأولوية |
|---|---|---|---|
| 1 | المصادقة | JWT يُتحقق منه بخوارزمية صريحة، مُصدر، وجمهور. لا alg: none. | حرج |
| 2 | انتهاء الرمز | رموز الوصول تنتهي في 15 دقيقة أو أقل. رموز التحديث تتدور عند الاستخدام. | حرج |
| 3 | تخزين الرمز | رموز التحديث في كوكيز httpOnly آمنة. لا رموز في localStorage. | حرج |
| 4 | التفويض على كل نقطة نهاية | كل نقطة وصول بيانات تتحقق من صلاحيات مستوى الكائن. BOLA مُختبر. | حرج |
| 5 | التحقق من المدخلات | جميع مدخلات المستخدم مُتحقق منها بـ Zod أو ما يعادله. لا req.body خام في الاستعلامات. | حرج |
| 6 | حقن SQL/NoSQL | جميع استعلامات قاعدة البيانات تستخدم استعلامات معلمية أو طرق ORM. لا ربط سلاسل. | حرج |
| 7 | تحديد المعدل | نقاط المصادقة: 5/15 دقيقة. API عام: 60/دقيقة. رؤوس حد المعدل تُرجع. | عالٍ |
| 8 | CORS | قائمة سماح أصول صريحة. لا حرف بدل مع بيانات الاعتماد. Preflight مُخزّن. | عالٍ |
| 9 | رؤوس الأمان | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy كلها موجودة. | عالٍ |
| 10 | معالجة الأخطاء | أخطاء الإنتاج تُرجع رسائل عامة. لا تتبع مكدس، لا أخطاء SQL مكشوفة. | عالٍ |
| 11 | الأسرار | لا أسرار في الكود أو تاريخ git. .env في .gitignore. مُتحقق منها عند البدء. | حرج |
| 12 | التبعيات | npm audit نظيف (لا عالٍ/حرج). ملف القفل ملتزم. npm ci في CI. | عالٍ |
| 13 | HTTPS فقط | HSTS مفعّل مع preload. HTTP يُعيد التوجيه إلى HTTPS. علامة الكوكي الآمن مُعيّنة. | حرج |
| 14 | التسجيل | أحداث المصادقة ورفض الوصول وتغيير البيانات مُسجلة. لا PII في السجلات. | متوسط |
| 15 | حدود حجم الطلب | محلل الجسم محدود (1 ميغابايت افتراضي). رفع الملفات محدد. تصفح الصفحات مُطبق. | متوسط |
| 16 | حماية SSRF | روابط المستخدم مُتحقق منها. عناوين IP الخاصة محظورة. إعادات التوجيه معطلة أو مُتحقق منها. | متوسط |
| 17 | قفل الحساب | محاولات تسجيل الدخول الفاشلة تُفعّل القفل بعد 5 محاولات. القفل مُسجل. | عالٍ |
| 18 | التحقق من Webhook | جميع webhooks الواردة مُتحقق منها بالتوقيعات. حماية الإعادة عبر الطابع الزمني. | عالٍ |
| 19 | نقاط نهاية المدير | التحكم في الوصول المبني على الأدوار على جميع مسارات المدير. المحاولات مُسجلة. | حرج |
| 20 | التعيين الجماعي | نقاط نهاية التحديث تستخدم مخطط Zod بحقول مسموح بها. لا نشر جسم خام. | عالٍ |
أحتفظ بهذه كقالب مشكلة GitHub. قبل وضع علامة إصدار، يجب على شخص في الفريق فحص كل صف والتوقيع. ليس ملفتاً، لكنه يعمل.
تغيير العقلية#
الأمان ليس ميزة تضيفها في النهاية. ليس سباقاً تقوم به مرة في السنة. إنه طريقة تفكير في كل سطر كود تكتبه.
عندما تكتب نقطة نهاية، فكر: "ماذا لو أرسل شخص ما بيانات لا أتوقعها؟" عندما تضيف معاملاً، فكر: "ماذا لو غيّر شخص ما هذا إلى معرف شخص آخر؟" عندما تضيف تبعية، فكر: "ماذا يحدث إذا تم اختراق هذه الحزمة يوم الثلاثاء القادم؟"
لن تلتقط كل شيء. لا أحد يفعل. لكن المرور على قائمة التحقق هذه — بشكل منهجي، قبل كل نشر — يلتقط الأشياء الأكثر أهمية. المكاسب السهلة. الثغرات الواضحة. الأخطاء التي تحول يوماً سيئاً إلى خرق بيانات.
ابنِ العادة. شغّل القائمة. انشر بثقة.