تخطَّ إلى المحتوى
CodeAuditAgent
كل المقالات

مزالق أمان JWT والجلسات، وكيف تتجنبها

أخطاء JWT والجلسات التي تؤدي إلى الاستيلاء على الحسابات: الخلط بين الخوارزميات، والأسرار الضعيفة، وغياب فحص المطالبات، والتخزين، والتدوير، وتسجيل الخروج.

· قراءة في 7 دقائق · Lina Source LLC

رموز JSON Web Tokens صيغة معقولة ذات قائمة طويلة من الحواف الحادة. والرمز نفسه نادرًا ما يكون المشكلة. فالأخطاء تعيش في كيفية التحقق منه، وأين يُخزَّن، وكم يعيش، وماذا يحدث حين يسجّل المستخدم خروجه. ولكل من هذه الأخطاء النتيجة نفسها: شخص يحمل رمزًا لا ينبغي أن يحمله، وخادمك يقبله.

وأكثر نقطتي ضعف ظهورًا هما CWE-347، أي التحقق غير السليم من توقيع تعمية، وCWE-613، أي انتهاء صلاحية الجلسة غير الكافي. وتستخدم الأمثلة أدناه مكتبة jose، وهي مكتبة JavaScript واسعة الانتشار لرموز JWT تعمل في Node.js وبيئات التشغيل الطرفية والمتصفحات.

فك الترميز ليس تحققًا

أبسط صور CWE-347 هي قراءة المطالبات من رمز دون فحص توقيعه. فلكل مكتبة JWT دالة فك ترميز للتنقيح، وهي تظهر في وسيط المصادقة أكثر مما ينبغي. والرمز المفكوك ليس إلا base64 يستطيع أي شخص كتابته. وفي قواعد كود JavaScript يختبئ النمط غالبًا في دالة مساعدة صغيرة تقسم الرمز عند النقاط وتشغّل JSON.parse على الجزء الأوسط. وهي تعمل في كل اختبار، لأن رموز الاختبار صالحة، وتقبل كل رمز مزوّر في الإنتاج.

import { decodeJwt, jwtVerify } from 'jose';

// Vulnerable: anyone can mint a token with sub set to any user ID
const claims = decodeJwt(token);
const userId = claims.sub;

// Correct: the signature, algorithm and claims are checked first
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;

قيمة alg none والخلط بين الخوارزميات

تذكر ترويسة JWT الخوارزمية التي وقّعت الرمز، والمهاجم هو من يتحكم بالترويسة. ومن الثقة بها ينشأ هجومان كلاسيكيان. الأول هو ضبط alg على none: رمز غير موقّع كانت بعض المكتبات القديمة تقبله بوصفه صالحًا. والثاني هو الخلط بين الخوارزميات: خادم يتوقع RS256 لكنه يدع الترويسة تختار الخوارزمية يمكن أن يُسلَّم رمز HS256 موقّعًا بالمفتاح العام للخادم مستخدمًا سرًّا لـ HMAC. والمفتاح العام عام، فيستطيع المهاجم توقيع أي شيء.

وتدافع المكتبات الحديثة عن الحالتين، وترفض jose الرموز غير المؤمّنة في jwtVerify وتتحقق من تطابق نوع المفتاح مع الخوارزمية. لا تعتمد على الافتراضيات وحدها. ثبّت قائمة الخوارزميات صراحةً في كل استدعاء تحقق، حتى لا توسّعها إعادة هيكلة مستقبلية أو تبديل مكتبة بصمت. وإن كنت تقبل أكثر من خوارزمية، مثلًا أثناء ترحيل مفاتيح، فاذكر كلتيهما صراحةً واستخدم مفتاحًا منفصلًا لكل منهما. وحين تدوّر المفاتيح، ضع kid في الترويسة واختر المفتاح بحسب kid من قائمتك أنت، لا من رابط مذكور في ترويسة الرمز أبدًا.

أسرار توقيع ضعيفة

يُوقَّع رمز HS256 بسرّ مشترك. وإذا كان السر قصيرًا أو قابلًا للتخمين، مثل 'secret' أو اسم التطبيق أو قيمة منسوخة من درس تعليمي، فإن مهاجمًا يملك رمزًا صالحًا واحدًا يستطيع كسره بالقوة الغاشمة دون اتصال بأدوات متاحة للجميع، ثم توقيع رموز لأي مستخدم. ولا يوجد حدّ معدل على التخمين غير المتصل.

  • استخدم 32 بايتًا عشوائيًا على الأقل لـ HS256، مولّدة بشيء مثل openssl rand -base64 32.
  • حمّل السر من البيئة أو من مدير أسرار، وافشل عند الإقلاع إذا كان غائبًا أو قصيرًا.
  • لا تودعه في المستودع أبدًا، ودوّره إذا سبق أن أُودع.
  • إذا كانت عدة خدمات تحتاج التحقق من الرموز بينما ينبغي لواحدة فقط إصدارها، فاستخدم خوارزمية غير متماثلة مثل RS256 أو EdDSA حتى لا يحمل المتحققون إلا المفتاح العام.

تحقق من exp وaud وiss

التوقيع الصالح لا يثبت إلا من أصدر الرمز. أما المطالبات فهي التي تقرر إن كان موجّهًا إليك وإن كان ما زال ساريًا. فالرمز بلا انتهاء صلاحية صالح إلى الأبد. والرمز الصادر لواجهة برمجة تطبيقك المحمول لا ينبغي أن تقبله خدمة الإدارة لديك لمجرد أن كليهما يثق بمزوّد الهوية نفسه. وهذا هو الغرض من aud وiss.

import { SignJWT, jwtVerify } from 'jose';

const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
  throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';

export function signAccessToken(userId: string, tokenVersion: number) {
  return new SignJWT({ tv: tokenVersion })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('10m')
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

تفحص jose قيمة exp كلما كانت موجودة، لكن رمزًا بلا exp كان سيمرّ لولا ذلك. وخيار requiredClaims يسدّ هذه الفجوة. أما الرموز القادمة من مزوّد هوية خارجي، فتحقق منها مقابل مجموعة مفاتيحه المنشورة عبر createRemoteJWKSet مع تثبيت الخوارزميات والمُصدِر والجمهور أيضًا.

localStorage أم ملفات تعريف ارتباط httpOnly

تخزين الرمز في localStorage يجعله قابلًا للقراءة من أي سكربت في الصفحة. فخلل XSS واحد، أو سكربت طرف ثالث مخترق واحد، يكفي ليُرسل الرمز إلى مهاجم ويُستخدم من أي مكان حتى تنتهي صلاحيته.

أما ملف تعريف الارتباط بخاصية httpOnly فلا تستطيع JavaScript قراءته. وتظل ثغرة XSS خطيرة، لأن السكربت المحقون يستطيع إرسال طلبات نيابة عن المستخدم ما دامت الصفحة مفتوحة، لكنه لا يستطيع سرقة بيانات اعتماد طويلة العمر وإعادة تشغيلها لاحقًا. وفي تطبيقات المتصفح التي تخاطب خادمها الخلفي، ملفات تعريف الارتباط هي الافتراض الأفضل. أما في تطبيق أحادي الصفحة يستدعي واجهة برمجة منفصلة، فإبقاء رمز الوصول في الذاكرة فقط ورمز التحديث في ملف تعريف ارتباط httpOnly حلّ وسط معقول.

// Express: session cookie with safe attributes
res.cookie('__Host-session', accessToken, {
  httpOnly: true, // not readable from JavaScript
  secure: true, // HTTPS only; required by the __Host- prefix
  sameSite: 'lax', // not sent on cross-site POSTs
  path: '/', // required by the __Host- prefix
  maxAge: 10 * 60 * 1000, // milliseconds, matches the token lifetime
});

تخبر البادئة -__Host المتصفح برفض ملف تعريف الارتباط ما لم يكن Secure، وله path مضبوط على /، وبلا خاصية Domain، وهو ما يمنع نطاقًا فرعيًا مخترقًا من الكتابة فوقه.

SameSite وما لا تغطيه

تحجب SameSite=Lax ملف تعريف الارتباط في طلبات POST العابرة للمواقع وفي تحميل الموارد الفرعية، وهو ما يزيل معظم هجمات CSRF الكلاسيكية. لكنها ما زالت ترسل ملف تعريف الارتباط في عمليات التنقل العلوية بـ GET، ولذا فإن أي نقطة نهاية GET تغيّر الحالة معرّضة. وتحجب Strict تلك أيضًا، لكنها تُخرج المستخدمين حين يتبعون رابطًا إلى تطبيقك من بريد أو موقع آخر. أما None فتعطّل الحماية وتتطلب Secure.

وLax مع قاعدة تقضي بألا تغيّر طلبات GET الحالة أساس سليم. وللتعديلات الحساسة، أضف فحصًا لترويسة Origin أو رمز CSRF. وتذكّر أن SameSite تعامل كل النطاقات الفرعية لنطاقك القابل للتسجيل بوصفها من الموقع نفسه، ولذا يظل نطاق فرعي مصاب قادرًا على تزوير الطلبات.

التدوير والإبطال وتسجيل الخروج الحقيقي

لا يمكن إبطال رمز JWT عديم الحالة قبل انتهاء صلاحيته؛ تلك هي المقايضة مقابل عدم مراجعة قاعدة بيانات في كل طلب. ويصف CWE-613 ما يحدث حين تُتجاهل هذه المقايضة: تسجيل خروج وتغييرات كلمات مرور وتعليق حسابات لا تنهي الوصول فعليًا. وحذف حساب وتغيير دور وإخراج شخص من فريق كلها أحداث إبطال أيضًا، وكل منها ينبغي أن يسري في الطلب التالي، لا عند انتهاء صلاحية الرمز التالي.

  • أبقِ رموز الوصول قصيرة العمر، في نطاق الدقائق لا الأيام.
  • خزّن رموز التحديث على جهة الخادم مُجزّأة، ودوّرها عند كل استخدام.
  • إذا استُخدم رمز تحديث سبق تدويره مرة أخرى، فعامل ذلك بوصفه سرقة وأبطل عائلة الرموز كلها.
  • أضف نسخة رمز إلى صف المستخدم وإلى الرمز؛ وارفعها عند تسجيل الخروج من كل الأجهزة أو تغيير كلمة المرور أو التعليق.
  • أعد توليد معرّف الجلسة عند تسجيل الدخول لمنع تثبيت الجلسة.
export async function requireUser(token: string) {
  const payload = await verifyAccessToken(token);

  const user = await db.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, tokenVersion: true, disabled: true },
  });

  // A bumped version or a disabled account ends access immediately
  if (!user || user.disabled || user.tokenVersion !== payload.tv) {
    throw new Error('Session revoked');
  }
  return user;
}

يعيد ذلك الاستعلام قراءة واحدة من قاعدة البيانات في كل طلب، وهي التكلفة الصادقة للإبطال. وكثير من التطبيقات أبسط وأأمن بمعرّف جلسة معتم في ملف تعريف ارتباط وجدول جلسات. ويصبح تسجيل الخروج عندئذٍ حذف صف. استخدم رموز JWT حيث تفيد خصائصها فعلًا، مثل الرموز قصيرة العمر بين الخدمات، لا لأنها الافتراض في درس تعليمي.

وأيًا كان اختيارك، يجب أن يقع تسجيل الخروج على الخادم. فمسح ملف تعريف الارتباط أو حذف الرمز في المتصفح لا يزيل إلا نسخة المستخدم؛ أما النسخة المسروقة فتظل تعمل حتى يرفضها الخادم. وعند تسجيل الخروج، احذف الجلسة أو رمز التحديث على جهة الخادم، وامسح ملف تعريف الارتباط بالاسم والمسار والخصائص نفسها التي ضُبط بها، وارفع نسخة الرمز إذا اختار المستخدم الخروج من كل الأجهزة. كما ينبغي لإعادة تعيين كلمة المرور أن تنهي كل الجلسات الأخرى.

قائمة مراجعة قصيرة

  • ابحث عن استدعاءات فك الترميز في مسارات المصادقة.
  • تأكد من أن كل استدعاء تحقق يثبّت الخوارزميات والمُصدِر والجمهور، ويشترط exp.
  • افحص كيف يُولَّد سرّ التوقيع ويُحمَّل ويُتحقق منه عند الإقلاع.
  • اعثر على المواضع التي تُخزَّن فيها الرموز في المتصفح.
  • اختبر أن تسجيل الخروج وتغيير كلمة المرور وتعليق الحساب تنهي الجلسات القائمة.

هذه الفحوص تقوم في معظمها على قراءة مسارات الكود من طرف إلى طرف، وهي أيضًا طريقة CodeAuditAgent في التدقيق: تأتي النتائج مع السطر المقتبس ومعرّف CWE وسيناريو استغلال وتصحيح، بحيث يمكن تأكيد فحص جمهور غائب وإصلاحه في دقائق.