پرش به محتویات

امنیت

چه چیزی ذخیره می‌شود و چه چیزی نه

اینجا هیچ اعتبارنامه‌ای به شکلی که بشود بازپخشش کرد ماندگار نمی‌شود.

اعتبارنامه ستون الگوریتم
رمز عبور users.password_hash Argon2id، با پیش‌فرض‌های argon2-cffi
کد اتصال link_tokens.token_digest SHA-256
توکن تازه‌سازی refresh_tokens.token_digest SHA-256
کلید API api_keys.token_digest SHA-256

برای رمز عبور Argon2، چون آدم انتخابش می‌کند و باید از حملهٔ برون‌خطی جان سالم به در ببرد. برای بقیه SHA-256، چون ۳۲ بایت خروجی secrets هستند — دیکشنری‌ای نیست که رویشان اجرا شود، و هزینهٔ Argon2 در هر درخواست چیزی نمی‌خرید.

یک نسخهٔ کامل از دیتابیس هیچ اعتبارنامهٔ قابل‌استفاده‌ای به مهاجم نمی‌دهد.

سه نوع توکن

عمداً با هم فرق دارند، و همین فرق‌ها طراحی‌اند.

توکن دسترسی — یک JWT امضاشده

هیچ چیزی با شناسه دنبالش نمی‌گردد. با تأیید امضا بررسی می‌شود که ارزان است، و عمر کوتاهی دارد: پیش‌فرض ۳۰ دقیقه.

اما می‌شود زودتر تمامش کرد، با دقیقاً یک اهرم. هر توکن token_generation حساب را در لحظهٔ ضرب‌شدنش حمل می‌کند، و revoke_all_for_user آن شمارنده را جلو می‌برد — پس تغییر رمز یا «خروج از همه‌جا» توکن‌های صادرشده را فوراً بی‌اعتبار می‌کند، به‌جای اینکه تا پایان عمرشان کار کنند. بررسی‌اش هزینه‌ای ندارد: سطر حساب از قبل برای دیدن فعال‌بودنش بارگذاری شده.

شمارنده و نه مُهر زمانی، چون iat دانه‌بندی یک‌ثانیه‌ای دارد. توکنی که ضرب شده و برشی که در همان ثانیه تعیین شده برابر مقایسه می‌شوند و توکن قدیمی رد می‌شود — که دقیقاً همان چیزی است که یک آزمون زندهٔ دود پیش از انتشار گرفت.

typ: "access" در بار قرار دارد و موقع ورود بررسی می‌شود، پس توکن تازه‌سازی را نمی‌شود به‌عنوان حامل ارائه کرد.

توکن تازه‌سازی — یک راز تصادفی، ذخیره‌شده به شکل خلاصه

در هر استفاده جست‌وجو می‌شود، و همین است که قابل ابطالش می‌کند. کل دلیل وجودش همین است.

چرخش: هر تازه‌سازی توکن تازه‌ای ضرب می‌کند و آن یکی را که از دلش آمده باطل می‌کند.

تشخیص سرقت: اگر توکنی که قبلاً مبادله شده دوباره ارائه شود، دو طرف دارندش و راهی نیست بفهمیم کدامشان می‌پرسد. کل خانواده — هر توکنی که از آن ورود نسل گرفته — باطل می‌شود. دارندهٔ قانونی بیرون انداخته می‌شود، که آسیبی به‌مراتب کوچک‌تر از گزینهٔ دیگر است.

ورود   →  T1
T1     →  T2   (T1 باطل، replaced_by = T2)
T1     →  ✗    خانواده باطل شد؛ T2 هم می‌میرد

آن ابطال پیش از پرتاب خطا کامیت می‌شود. این تنها جایی است که یک سرویس کار خودش را کامیت می‌کند: درخواست در آستانهٔ شکست است، فراخوان هنگام شکست به عقب برمی‌گردد، و بازگشت به عقب ابطال را خنثی می‌کرد — یعنی خانوادهٔ دزدیده‌شده زنده می‌ماند و تشخیص هیچ کاری نمی‌کرد. همین‌طور نوشته شد، همین‌طور منتشر شد، و یک تست گرفتش.

کلید API — برای برنامه‌ها، نه آدم‌ها

یک بار، موقع ساخت، نشان داده می‌شود. خودبه‌خود منقضی نمی‌شود، چون کار شبانه نباید ساعت سه بامداد بایستد. پیشوند هشت‌کاراکتری آشکار نگه داشته می‌شود تا بشود کلید را در فهرست شناخت و بدون تولید دوباره‌اش باطلش کرد.

با X-API-Key فرستاده می‌شود. به همان Userی می‌رسد که توکن حامل می‌رسد، پس مسیری که دفترها را می‌خواند یا می‌نویسد لازم نیست بداند کدامشان آمده.

مدیریت خود حساب استثناست و کلید را با ۴۰۳ رد می‌کند: ساختن، دیدن و باطل‌کردن کلیدها؛ نمایه، راه تماس و رمز؛ نشست‌ها و خروج از همه‌جا؛ بستن حساب؛ و وصل یا عوض‌کردن پیام‌رسان. کلیدی که می‌توانست کلید بسازد، باطل‌کردن کلید لورفته را بی‌اثر می‌کرد — دارنده‌اش جانشینش را از پیش ساخته بود — و کلیدی که می‌توانست راه تماس را عوض کند یا حساب بی‌رمز را ببندد، خودِ حساب بود.

نمایش یک راز روی صفحه

کلید API یک بار، موقع ساخت، نشان داده می‌شود و برای پنهان‌سازی علامت می‌خورد — تلگرام آن را پشت یک لمس نشان می‌دهد. پیام‌رسان‌هایی که نمی‌توانند متن را پنهان کنند باز هم نشانش می‌دهند، چون کلیدی که هیچ‌کس نمی‌بیندش بدتر از کلیدی است که فقط پنهان نیست، و Capabilities.spoiler همان جایی است که هر پیام‌رسان می‌گوید کدام‌یکی است.

تجزیهٔ HTML فقط برای همان یک پیام روشن می‌شود، و هر دو نیمه اول گریزبندی می‌شوند. روشن‌کردنش در همه‌جا یعنی توضیحی فارسی که < یا & داشته باشد می‌تواند صفحه‌ای را بشکند یا ارسال نشود.

محدودیت نرخ

پنجره‌های ثابت، هم به ازای نشانی و هم به ازای حساب:

مسیر سقف
POST /auth/login ۵ در دقیقه برای هر IP، ۱۰ در دقیقه برای هر شناسه
POST /auth/register ۳ در ساعت برای هر IP

هر دو، چون هیچ‌کدام به‌تنهایی کافی نیست. فقط بر مبنای IP یعنی یک بات‌نت می‌تواند یک حساب را بکوبد؛ فقط بر مبنای حساب یعنی یک مهاجم می‌تواند با امتحان نشانی‌ها همهٔ کاربران را بیرون قفل کند.

پنجرهٔ ثابت زیباترین الگوریتم نیست — انفجاری که روی مرز بیفتد لحظه‌ای دو برابر سهمیه می‌گیرد. این معاملهٔ قابل‌قبولی است برای چیزی که مسیر ورود در هر درخواست از آن می‌پرسد. آنچه می‌خرد همان چیزی است که مهم است: حدس‌زدن رمز دیگر رایگان نیست.

با ردیس بین کارگرها مشترک است. بدونش، درون‌پروسه است — روی یک کارگر درست و روی چندتا بی‌فایده، و API موقع بالا آمدن هشدار می‌دهد به‌جای اینکه وانمود کند این‌طور نیست. شمارندهٔ درون‌پروسه پنجره‌های تمام‌شده را فراموش می‌کند، پس هجوم کلیدهای مختلف نمی‌تواند حافظه را بی‌پایان بزرگ کند. شمارندهٔ ردیس همراه با زمان انقضایش در یک تراکنش ساخته می‌شود (SET NX EX و بعد INCR)، پس خرابی وسط کار نمی‌تواند شمارنده‌ای بسازد که هرگز صفر نشود؛ شمارنده‌ای که نسخهٔ قدیمی بدون انقضا جا گذاشته باشد در اولین درخواست بعدی انقضا می‌گیرد.

درخواست ردشده 429 است با هدر Retry-After به ثانیه.

شمارش حساب‌ها

رمز غلط و حساب ناشناخته همان وضعیت و همان بدنه را برمی‌گردانند. دو پاسخ متفاوت مسیر ورود را به ابزاری برای فهمیدن اینکه چه کسی حساب دارد تبدیل می‌کرد.

همین قاعده جای دیگر هم هست: books.require برای غیرعضو NotFound می‌دهد نه PermissionDenied، پس شناسهٔ دفترها را نمی‌شود کاوید. تلاش برای باطل‌کردن کلید API دیگری ۴۰۴ می‌دهد، نه ۴۰۳.

وب‌هوک‌ها

اینجا تنها جایی است که پاسخ صادقانه ناخوشایند است.

تلگرام توکن رازی را که ثبت کرده‌ایم در X-Telegram-Bot-Api-Secret-Token بازتاب می‌دهد. با hmac.compare_digest مقایسه می‌شود. تأیید واقعی.

بله و روبیکا هیچ چیزی را امضا نمی‌کنند. نه هدری، نه امضایی، نه راز مشترکی. verify_webhook آداپتورهایشان True برمی‌گرداند و رشتهٔ مستندش صریح می‌گوید که نمی‌تواند ثابت کند فراخوان همانی است که ادعا می‌کند — چون ادعای بررسی‌ای که انجام نمی‌شود بدتر از اعتراف به نبودنش است.

آنچه به‌جایش محافظتشان می‌کند مسیر است: /api/v1/webhooks/{provider}/{secret}، مقایسه در زمان ثابت، ۴۰۴ در صورت عدم‌تطابق. پیام‌رسان پیکربندی‌نشده و راز غلط همان ۴۰۴ را می‌دهند، پس کاوشگر نمی‌فهمد کدامش را اشتباه زده.

این ضعیف‌تر از امضاست. همانی است که این پیام‌رسان‌ها ممکن می‌کنند.

پولینگ کلاً از این سؤال فرار می‌کند و پیش‌فرض است. اگر عوضش کردی — KASBBOOK_UPDATE_MODE=webhook — راز مسیر برای بله و روبیکا باربر می‌شود، و nginx نباید لاگش کند. عملیات را ببین.

رمزی که در گفتگو تایپ می‌شود

ربات می‌تواند رمز تعیین کند، یعنی کسی رمزی را در تلگرام تایپ می‌کند. این فقط به این دلیل قابل‌قبول است که پیام همان لحظه که رسیدگی شد حذف می‌شود — apps/bot/runner.py هر پیام ورودی را برمی‌دارد، و یک تست مشخصاً مورد رمز را ادعا می‌کند. بدونش رمز در گفتگو می‌ماند، روی دستگاه، و در هر پشتیبانی از هر دوی این‌ها.

حذفی که شکست بخورد — خیلی قدیمی باشد، یا در گروه دسترسی نباشد — لاگ و نادیده گرفته می‌شود، به‌جای اینکه به‌روزرسانی‌ای را که قبلاً موفق شده شکست بدهد. خوب است بدانی که همین تضمین را از مطلق به «بهترین تلاش» تبدیل می‌کند.

تغییر رمز موجود، رمز فعلی را می‌خواهد. کسی که پیام‌رسان متصل را دارد از قبل می‌تواند دفترها را بخواند و عوض کند، پس این کمتر از چیزی که به نظر می‌رسد می‌خرد؛ آنچه می‌خرد این است که گوشی دزدیده‌شده نتواند بی‌سروصدا سمت وب را هم بگیرد. تعیین رمز اول چیزی نمی‌پرسد، چون چیزی برای پرسیدن نیست.

در هر حال، بعدش همهٔ نشست‌ها باطل می‌شوند — هم توکن‌های تازه‌سازی و هم توکن‌های دسترسی صادرشده.

رازها در لاگ

httpx نشانی کامل درخواست را در سطح INFO لاگ می‌کند، و در همهٔ این APIهای ربات توکن داخل مسیر است. یک بار همین‌طور توکنی سر از ژورنال اینجا درآورد.

apps/bot/runner.py سطح httpx و httpcore را روی WARNING قفل می‌کند. اختیاری نیست و توضیحی دارد که می‌گوید چرا.

استثناهای مدیریت‌نشده کامل لاگ می‌شوند و به فراخوان به شکل {"detail": "something went wrong on our side"} گزارش می‌شوند — متن یک استثنا می‌تواند کوئری، مسیر، یا مقداری از حساب کس دیگری را حمل کند.

ترابری

API فقط به 127.0.0.1 گوش می‌دهد. TLS و محدودیت نرخ لبه به پراکسی معکوسی تعلق دارند که از قبل روی سرور هست، نه به پروسهٔ برنامه.

مبدأهای CORS صریح فهرست می‌شوند و هرگز * نیستند. یک همه‌کاره در کنار اعتبارنامهٔ روشن، حفره است نه راحتی — و تا وقتی KASBBOOK_CORS_ORIGINS تنظیم نشده کلاً خاموش است.

X-Forwarded-For فقط وقتی برای محدودیت نرخ معتبر است که KASBBOOK_TRUSTED_PROXY بگوید پراکسی‌ای جلو هست. بدونش هر کسی می‌تواند آن هدر را بگذارد و محدودیت نرخ تزئینی می‌شود. پشت آن پراکسی، آخرین نشانی فهرست همان مشتری است: پراکسی نشانی‌ای را که دیده به انتهای هر چه فرستنده گذاشته می‌افزاید، پس هر چه سمت چپش باشد ادعای خود فرستنده است.

پاکیزگی ورودی و خروجی

  • خروجی CSV به هر سلول متنی (دسته، ارز، توضیح) که با =، +، -، @، تب یا CR شروع شود یک ' اضافه می‌کند تا صفحه‌گسترده آن را نشان دهد نه اجرا. ستون‌های مبلغ عدد می‌مانند.
  • جست‌وجو نویسه‌های % و _ را عادی می‌گیرد، نه جایگزین LIKE.
  • مبلغ باید کمتر از ۱۰ به توان ۲۴ باشد (پهنای ستون ذخیره)؛ بزرگ‌تر با ۴۲۲ رد می‌شود نه با شکست درون پایگاه‌داده.

جداسازی پروسه

هر دو یونیت systemd با همان دسترسی‌ای اجرا می‌شوند که واقعاً لازم دارند، یعنی هیچ:

NoNewPrivileges  PrivateTmp  ProtectSystem=strict  ProtectHome
ReadWritePaths=/opt/kasbbook  ProtectKernelTunables  ProtectControlGroups

ربات تماس HTTPS خروجی می‌گیرد و با لوپ‌بک حرف می‌زند. بقیهٔ چیزها رد می‌شوند.

آنچه عمداً ساخته نشده

  • بدون ورود دومرحله‌ای. ارزش داشتنش را دارد؛ هنوز نوشته نشده. در نقشهٔ راه.
  • بدون بازنشانی رمز با ایمیل. مسیر ایمیلی وجود ندارد، و جریان بازنشانی بدونش تیکت پشتیبانی‌ای است که خودش را امکان جا زده.
  • بدون بازیابی حساب حذف‌شده. DELETE /auth/me فوری و برگشت‌ناپذیر است: نه مهلتی هست نه بازگشتی، و نقطهٔ پایانی پیش‌نمایش هست تا تصمیم با دیدن پیامدها گرفته شود. وقتی حساب رمز دارد رمز می‌خواهد، و تا وقتی دفتر مشترکی بی‌صاحب می‌ماند رد می‌کند — مدل داده را ببین.
  • بدون APIهای غیررسمی پیام‌رسان‌ها. نه نقطهٔ پایانی مهندسی‌معکوس‌شدهٔ وب کلاینت، نه ورود به‌عنوان انسان با کد یک‌بارمصرف. ربات به‌عنوان ربات پست می‌گذارد.