Levelwise
فارسی
معماری و طراحی سیستم

امنیت در برنامه‌های .NET

به هیچ ورودی اعتماد نکن و برای هر داده بپرس «این کاربر اجازه دارد؟». رمز عبور را هش کن، داده حساس را رمزنگاری کن و کلیدها را بیرون از کد نگه دار. چند لایه دفاع بساز تا شکستن یک لایه همه چیز را خراب نکند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: سه خبر بد در یک هفته

فروشگاه اینترنتی ما تازه راه افتاده است. در یک هفته سه اتفاق می‌افتد:

  1. علی سفارش‌های دیگران را می‌بیند. در آدرس صفحه، عدد سفارش خودش را از ۴۱ به ۴۲ تغییر می‌دهد. سرور سفارش سارا را با آدرس و تلفنش نشان می‌دهد.
  2. کلید درگاه پرداخت لو می‌رود. یک نفر مخزن کد را برای یک پیمانکار باز کرده است. کلید داخل فایل تنظیمات بود.
  3. دیتابیس کاربران کپی می‌شود. رمزهای عبور به شکل ساده ذخیره شده بودند. حالا مهاجم رمز همه را دارد. خیلی از مردم همین رمز را برای ایمیلشان هم استفاده می‌کنند.

هیچ کدام از این‌ها حمله پیچیده‌ای نیست. هر سه از اشتباه‌های ساده و رایج می‌آیند. این درس درباره همین اشتباه‌ها است.

ایده اصلی: چند لایه دفاع

هیچ لایه‌ای کامل نیست. پس به یک لایه تکیه نمی‌کنیم. به این روش «دفاع در عمق» (Defense in Depth) می‌گوییم.

هر لایه جلوی یک نوع حمله را می‌گیردکاربریا مهاجمراه رمزشدهHTTPSچه کسی؟Authenticationاجازه دارد؟Authorizationورودی سالم؟Validationدسترسی کمLeast privilegeدیتابیسشنود در راهورود بدون رمزدیدن سفارش دیگرانتزریق دستورخسارت کمتراگر یک لایه شکست، لایه بعدی هنوز سر جایش است
درخواست از چند در عبور می‌کند. هر در یک سؤال جدا می‌پرسد.

سه قانون پایه داریم:

  1. به ورودی اعتماد نکن. هر چیزی که از بیرون می‌آید، حتی از اپلیکیشن موبایل خودمان، ممکن است دستکاری شده باشد.
  2. کمترین دسترسی را بده. کاربر دیتابیس برنامه نباید اجازه حذف جدول داشته باشد. اگر مهاجم وارد شد، خسارت کمتر است.
  3. پیش‌فرض، بسته باشد. اگر شک داری، اجازه نده. یک endpoint جدید باید خودکار به ورود نیاز داشته باشد، نه برعکس.

فهرست OWASP Top 10

گروه OWASP هر چند سال یک فهرست از رایج‌ترین خطرهای برنامه‌های وب منتشر می‌کند. در نسخه ۲۰۲۵، اولین مورد هنوز خرابی کنترل دسترسی (Broken Access Control) است. پیکربندی غلط و مشکل در زنجیره تأمین (بسته‌هایی که استفاده می‌کنیم) هم بالای فهرست هستند. تزریق دستور و رمزنگاری ضعیف هم هنوز در فهرست هستند.

لازم نیست فهرست را حفظ کنی. در مصاحبه، مهم این است که بتوانی هر خطر را در کد واقعی ببینی و راه جلوگیری‌اش را بگویی. در ادامه، مهم‌ترین‌ها را در فروشگاه خودمان می‌بینیم.

خطر اول: کنترل دسترسی

ورود کاربر (Authentication) فقط می‌گوید «این علی است». ولی نمی‌گوید «علی اجازه دیدن سفارش ۴۲ را دارد». این سؤال دوم کار مجوز (Authorization) است. اگر آن را فراموش کنیم، کاربر با عوض کردن یک عدد به داده دیگران می‌رسد. اسم این مشکل IDOR است.

علیوارد شده استGET /orders/42سفارش ۴۲ مال سارا است، نه علی.سرور بدون چک مالکWHERE Id = 42آدرس سارالو رفتسرور با چک مالکWHERE Id = 42 AND Owner = Aliپیدا نشد404ورود کاربر کافی نیست. باید بپرسیم این داده مال همین کاربر است یا نه
app.MapGet("/orders/{id:guid}", async (
    Guid id, ClaimsPrincipal user, ShopDb db, CancellationToken ct) =>
{
    var customerId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    // The owner check is part of the query, not an afterthought.
    var order = await db.Orders
        .Where(o => o.Id == id && o.CustomerId == customerId)
        .Select(o => new OrderDto(o.Id, o.Total, o.Status))
        .FirstOrDefaultAsync(ct);

    return order is null ? Results.NotFound() : Results.Ok(order);
})
.RequireAuthorization();

چند نکته در این کد:

  1. شناسه مشتری از توکن می‌آید، نه از ورودی. اگر شناسه مشتری را از آدرس یا بدنه درخواست بخوانیم، علی آن را هم عوض می‌کند.
  2. جواب 404 است، نه 403. با 403 به مهاجم می‌گوییم «این سفارش وجود دارد». با 404 هیچ چیز نمی‌گوییم.
  3. فقط فیلدهای لازم برمی‌گردند. کل Entity را برنگردان. شاید فیلدی مثل هزینه خرید از تأمین‌کننده هم داخلش باشد.
شناسه تصادفی کافی نیست. شناسه Guid حدس زدن را سخت می‌کند، ولی جای چک مالک را نمی‌گیرد. شناسه‌ها در لاگ، ایمیل و لینک‌ها پخش می‌شوند.

خطر دوم: تزریق دستور (Injection)

صفحه جستجوی محصول، متن کاربر را می‌گیرد. اگر این متن را مستقیم به رشته SQL بچسبانیم، کاربر می‌تواند به جای اسم محصول، دستور SQL بنویسد.

// BAD: the user text becomes part of the SQL command.
var bad = await db.Products
    .FromSqlRaw($"SELECT * FROM Products WHERE Name = '{name}'")
    .ToListAsync(ct);

// GOOD: FromSql turns {name} into a SQL parameter.
var good = await db.Products
    .FromSql($"SELECT * FROM Products WHERE Name = {name}")
    .ToListAsync(ct);

// BEST: plain LINQ. EF Core always uses parameters.
var best = await db.Products
    .Where(p => p.Name == name)
    .ToListAsync(ct);

چرا نسخه دوم امن است؟

  1. متد FromSql رشته را تکه‌تکه می‌گیرد. متن ثابت را جدا و مقدار name را جدا می‌گیرد.
  2. مقدار به شکل پارامتر فرستاده می‌شود. دیتابیس آن را فقط «داده» می‌بیند، نه «دستور».
  3. متد FromSqlRaw این کار را نمی‌کند. همان رشته نهایی را می‌فرستد. فرق این دو فقط یک کلمه است، پس در Code Review با دقت نگاه کن.
فقط SQL نیست. همین ایده برای دستورهای سیستم‌عامل، کوئری‌های LDAP و HTML هم درست است. در HTML، اگر متن کاربر بدون Encode در صفحه بیاید، اسکریپت مهاجم در مرورگر دیگران اجرا می‌شود (XSS). صفحه‌های Razor به طور پیش‌فرض متن را Encode می‌کنند.

رمز عبور: هش، نه رمزنگاری

سه کار شبیه هم داریم که خیلی وقت‌ها با هم قاطی می‌شوند:

ورودیروشبرمی‌گردد؟Ali123کدگذاریBase64بله، برای همههیچ امنیتی ندارد09121234567رمزنگاریAES + keyبله، فقط با کلیدبرای داده‌ای که بعداً لازم استP@ssw0rdهش کند با نمکPBKDF2 / Argon2نه، یک‌طرفه استفقط مقایسه می‌کنیم
برای رمز عبور هش کند با نمک. برای داده‌ای که بعداً باید بخوانیم، رمزنگاری.
  1. کدگذاری (Encoding). فقط شکل داده را عوض می‌کند. هر کسی آن را برمی‌گرداند. امنیت نیست.
  2. رمزنگاری (Encryption). با کلید برمی‌گردد. برای داده‌ای مناسب است که بعداً لازم داریم، مثل شماره تلفن مشتری.
  3. هش (Hashing). یک‌طرفه است. برای رمز عبور مناسب است. ما هیچ وقت لازم نیست رمز را بخوانیم. فقط باید چک کنیم که رمز واردشده درست است یا نه.

ولی هر هشی مناسب رمز نیست. هش سریع مثل SHA-256 برای رمز عبور بد است. مهاجم می‌تواند در یک ثانیه تعداد خیلی زیادی رمز را امتحان کند. پس دو چیز لازم داریم:

  1. نمک (Salt). یک مقدار تصادفی برای هر کاربر. دو کاربر با رمز یکسان، هش متفاوت می‌گیرند. جدول‌های آماده هش هم بی‌فایده می‌شوند.
  2. الگوریتم کند. مثل PBKDF2 یا Argon2. کند بودن برای ورود یک کاربر مهم نیست، ولی حدس زدن میلیون‌ها رمز را خیلی گران می‌کند.

لازم نیست این را خودت بنویسی. کلاس PasswordHasher در ASP.NET Core Identity نمک و الگوریتم کند را خودش مدیریت می‌کند:

var hasher = new PasswordHasher<Customer>();

// On sign-up: store only the hash (salt is inside it).
customer.PasswordHash = hasher.HashPassword(customer, password);

// On sign-in: compare, never decrypt.
var result = hasher.VerifyHashedPassword(customer, customer.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
    return Results.Unauthorized();
الگوریتم رمزنگاری خودت را نساز. کتابخانه‌های آماده سال‌ها حمله را تحمل کرده‌اند. برای رمزنگاری داده در ASP.NET Core، از Data Protection استفاده کن. این کتابخانه کلیدها را خودش می‌سازد و عوض می‌کند.

مدیریت Secret

کلید درگاه پرداخت، رمز دیتابیس و کلید امضای توکن همه Secret هستند. جای آن‌ها داخل مخزن کد نیست. حتی اگر بعداً از فایل پاک شوند، در تاریخچه گیت می‌مانند.

بدappsettings.jsonکلید پرداخت داخل فایلgit pushهر کس به مخزن دسترسی داردکلید را برای همیشه داردخوبسیستم توسعه‌دهندهUser Secretsمحیط واقعیKey Vaultبرنامه با یک اسم ثابت می‌خواندPayment:ApiKeyکد عوض نمی‌شود، فقط منبع تنظیمات عوض می‌شود

ایده این است: کد همیشه با یک اسم ثابت تنظیمات را می‌خواند. فقط منبع آن در هر محیط فرق می‌کند.

در سیستم توسعه‌دهنده، از User Secrets استفاده کن. مقدار بیرون از پوشه پروژه ذخیره می‌شود:

dotnet user-secrets init
dotnet user-secrets set "Payment:ApiKey" "test-key-for-local"

در محیط واقعی، از یک انبار Secret مثل Azure Key Vault استفاده کن. برنامه با هویت خودش (Managed Identity) وارد می‌شود. پس هیچ رمزی برای خواندن رمزها لازم نیست:

var builder = WebApplication.CreateBuilder(args);

if (builder.Environment.IsProduction())
{
    // Packages: Azure.Extensions.AspNetCore.Configuration.Secrets, Azure.Identity
    builder.Configuration.AddAzureKeyVault(
        new Uri("https://shop-vault.vault.azure.net/"),
        new DefaultAzureCredential());
}

// The same line works in every environment.
var apiKey = builder.Configuration["Payment:ApiKey"];
یک نکته کوچک: در Key Vault اسم Secret نمی‌تواند دونقطه داشته باشد. پس آن را Payment–ApiKey می‌نویسیم. کتابخانه دو خط تیره را به دونقطه تبدیل می‌کند.

اگر یک Secret لو رفت، پاک کردن آن از کد کافی نیست. اول کلید را عوض کن (Rotate). بعد ببین چه کسی و کجا از آن استفاده کرده است.

بقیه لایه‌ها در یک نگاه

  1. همیشه HTTPS. با متد UseHttpsRedirection درخواست‌های ساده را به HTTPS ببر. با متد UseHsts به مرورگر بگو دیگر هیچ وقت بدون HTTPS نیاید.
  2. بسته‌ها را به‌روز نگه دار. دستور dotnet list package با گزینه vulnerable بسته‌هایی را نشان می‌دهد که مشکل امنیتی شناخته‌شده دارند. این را در CI اجرا کن.
  3. خطای کامل را به کاربر نشان نده. صفحه خطای توسعه، مسیر فایل‌ها و جزئیات کد را نشان می‌دهد. در محیط واقعی فقط یک پیام کلی و یک شناسه خطا برگردان.
  4. رمز و Secret را لاگ نکن. لاگ‌ها به افراد بیشتری از دیتابیس می‌رسند. شماره کارت، رمز و توکن نباید در لاگ باشند.
  5. ورود را محدود کن. برای صفحه ورود، محدودیت تعداد درخواست (Rate Limiting) بگذار. وگرنه مهاجم هزاران رمز را پشت سر هم امتحان می‌کند.
  6. رویدادهای امنیتی را ثبت کن. ورود ناموفق، تغییر رمز و دسترسی ردشده را لاگ کن و برایشان هشدار داشته باش.

اشتباه‌های رایج

اشتباه نتیجه راه درست
فقط چک ورود، بدون چک مالک کاربر با عوض کردن عدد، داده دیگران را می‌بیند. مالک را داخل خود کوئری چک کن.
خواندن شناسه کاربر از ورودی درخواست کاربر خودش را جای دیگری جا می‌زند. شناسه را از توکن بخوان.
چسباندن متن کاربر به رشته SQL مهاجم دستور خودش را اجرا می‌کند. پارامتر، یا LINQ معمولی.
ذخیره رمز با هش سریع یا رمزنگاری با لو رفتن دیتابیس، رمزها هم لو می‌روند. کلاس PasswordHasher یا الگوریتم کند با نمک.
کلید در فایل تنظیمات داخل گیت هر کس مخزن را دارد، کلید را دارد. ابزار User Secrets و Key Vault.
پاک کردن Secret لورفته از کد کلید هنوز در تاریخچه گیت و دست مهاجم است. اول کلید را عوض کن.
فرستادن کل Entity در جواب API فیلدهای داخلی و حساس بیرون می‌روند. یک DTO فقط با فیلدهای لازم.

امنیت کجا شروع می‌شود؟

درست

  • از روز طراحی. برای هر endpoint جدید بپرس: «چه کسی اجازه دارد؟»
  • با پیش‌فرض‌های امن: همه endpointها به ورود نیاز دارند.
  • با چک خودکار بسته‌ها و Secretها در CI.
  • با Code Review که امنیت را هم نگاه می‌کند.

نادرست

  • در آخر پروژه، با یک تست نفوذ.
  • با اعتماد به اینکه «فرانت‌اند این دکمه را نشان نمی‌دهد».
  • با این فکر که «سیستم ما کوچک است، کسی حمله نمی‌کند». ربات‌ها همه سایت‌ها را خودکار امتحان می‌کنند.

خلاصه در شش خط

  1. هیچ لایه‌ای کامل نیست. چند لایه دفاع بساز.
  2. ورود کافی نیست. برای هر داده بپرس که این کاربر اجازه دارد یا نه.
  3. متن کاربر را هیچ وقت به دستور نچسبان. از پارامتر استفاده کن.
  4. رمز عبور را با الگوریتم کند و نمک هش کن. داده لازم را رمزنگاری کن.
  5. هیچ Secret نباید در مخزن کد باشد. اگر لو رفت، اول آن را عوض کن.
  6. بسته‌ها را به‌روز نگه دار و خطا و Secret را به بیرون نشان نده.