Blog Dla Programistów C#/.NET

Bezpieczeństwo aplikacji ASP.NET Core: 7 błędów w kodzie i jak je naprawić (IDOR, SQL Injection, XSS, SSRF)

wtorek, 29 września 2026 Tagi: C#/.NETProgramowanie

Masz w aplikacji HTTPS, Entity Framework Core i atrybut [Authorize] na kontrolerach. Czy to znaczy, że aplikacja jest bezpieczna? Niestety nie. Czasem wystarczy zmienić 1 identyfikator w adresie, dopisać 1 pole do JSON-a albo wysłać kilka szybkich żądań, żeby obejść zabezpieczenia, które na pierwszy rzut oka wyglądają solidnie.

W tym artykule przejdziemy przez 7 błędów bezpieczeństwa, które bardzo często znajduję w aplikacjach ASP.NET Core. Nie skończy się na teorii: przy każdym błędzie zobaczysz podatny kod, sposób jego wykorzystania, poprawioną wersję i krótką zasadę do zapamiętania. Na koniec dorzucę 2 bonusowe błędy, które utrudniają wykrycie ataku, oraz ściągę w formie 7 pytań do code review.

Na liście są m.in. IDOR (dostęp do cudzych rekordów), mass assignment, SQL Injection mimo EF Core, XSS w Blazorze, SSRF, sekrety w repozytorium i logowanie bez rate limitingu. Brzmi jak rozdział z podręcznika, ale każdy z tych błędów potrafi spokojnie przejść przez code review, bo kod "wygląda rozsądnie".

Wersje .NET: przykłady są napisane pod .NET 8, 9 i 10 (korzystam m.in. z primary constructors z C# 12). Wbudowany middleware do rate limitingu jest dostępny od .NET 7, a metoda FromSql - od EF Core 7. Same zasady są jednak uniwersalne i dotyczą każdej wersji.

Bezpieczeństwo aplikacji ASP.NET Core: 7 błędów w kodzie i jak je naprawić (IDOR, SQL Injection, XSS, SSRF)

Przykładowa aplikacja: SecureDocs


Wszystkie błędy pokażę na jednej, prostej aplikacji do przechowywania prywatnych dokumentów - nazwijmy ją SecureDocs. Architektura jest typowa: frontend w Blazorze (albo dowolne SPA), pod spodem ASP.NET Core Web API, a dane w bazie przez EF Core.

Użytkownik musi się zalogować, a jego identyfikator trafia do tokena albo cookie. W systemie są 2 konta: Alice (identyfikator user-1) i Bob (user-2). Każde z nich ma własny, prywatny dokument. Aplikacja ma uwierzytelnianie, bazę danych i porządne API, a mimo to zawiera kilka poważnych podatności. Sprawdzimy, czy samo zalogowanie użytkownika rzeczywiście chroni te dane.

Zanim zaczniemy, zapamiętaj jedno rozróżnienie, które przewija się przez cały artykuł:

• Uwierzytelnianie (authentication) odpowiada na pytanie: kim jesteś?
• Autoryzacja (authorization) odpowiada na pytanie: czy możesz wykonać tę operację na tym konkretnym zasobie?

To nie to samo - i właśnie od pomylenia tych 2 rzeczy zaczyna się pierwszy błąd.

Błąd 1: [Authorize] bez sprawdzania właściciela zasobu (IDOR)


Atrybut [Authorize] sprawdza, czy użytkownik jest zalogowany. Nie sprawdza jednak automatycznie, czy dokument pobierany z bazy rzeczywiście należy do tego użytkownika. Innymi słowy: [Authorize] nie oznacza "ten użytkownik może odczytać ten konkretny rekord".

Kod podatny. Spójrz na ten kontroler i zastanów się, czy widzisz problem:

[ApiController]
[Route("api/documents")]
[Authorize]
public sealed class DocumentsController(AppDbContext db) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<DocumentResponse>> Get(
Guid id,
CancellationToken cancellationToken)
{
var document = await db.Documents
.AsNoTracking()
.SingleOrDefaultAsync(
document => document.Id == id,
cancellationToken);

if (document is null)
{
return NotFound();
}

return Ok(new DocumentResponse(
document.Id,
document.Title,
document.Content));
}
}

Kod wygląda rozsądnie. Endpoint jest zabezpieczony atrybutem [Authorize], pobieramy dokument po identyfikatorze i zwracamy wynik (DocumentResponse to prosty rekord DTO z polami Id, Title i Content). Problem polega na tym, że nigdzie nie sprawdzamy właściciela dokumentu.

Atak. Wystarczy zalogować się jako Alice i poprosić o dokument Boba:

GET https://localhost:7001/api/documents/22222222-2222-2222-2222-222222222222
Authorization: Bearer TOKEN_ALICE

Odpowiedź: 200 OK i pełna treść prywatnego dokumentu Boba. Token Alice jest poprawny, więc żądanie przechodzi przez [Authorize]. Potem wystarczy podmienić identyfikator w adresie i API grzecznie oddaje cudze dane.

To klasyczny problem kontroli dostępu do konkretnego zasobu, znany jako IDOR (Insecure Direct Object Reference). W rankingu OWASP API Security Top 10 występuje pod nazwą BOLA (Broken Object Level Authorization) i zajmuje w nim 1. miejsce. Zauważ też, że GUID w adresie niczego tu nie ratuje. Identyfikator "nie do zgadnięcia" nie zastąpi sprawdzenia uprawnień, bo prędzej czy później wycieknie w logach, linkach albo odpowiedziach innych endpointów.

Poprawka. Dokument pobieramy nie tylko po jego identyfikatorze, ale też po identyfikatorze aktualnego użytkownika:

[HttpGet("{id:guid}")]
public async Task<ActionResult<DocumentResponse>> Get(
Guid id,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
{
return Unauthorized();
}

var document = await db.Documents
.AsNoTracking()
.Where(document =>
document.Id == id &&
document.OwnerId == userId)
.Select(document => new DocumentResponse(
document.Id,
document.Title,
document.Content))
.SingleOrDefaultAsync(cancellationToken);

if (document is null)
{
return NotFound();
}

return Ok(document);
}

Teraz Alice może pobrać wyłącznie dokument, który należy do Alice. Przy okazji projekcja przez Select sprawia, że z bazy pobieramy tylko pola potrzebne w odpowiedzi.

Dlaczego 404, a nie 403? Bo nie chcemy nawet potwierdzać, że dokument o takim identyfikatorze istnieje. Odpowiedź 403 Forbidden mówi atakującemu: "taki rekord jest, tylko nie Twój". 404 Not Found nie zdradza niczego.

Przy bardziej złożonych regułach (dokumenty współdzielone z zespołem, role, różne uprawnienia do odczytu i edycji) warto sięgnąć po resource-based authorization i IAuthorizationService. Sam atrybut [Authorize] nigdy nie zastąpi jednak sprawdzenia dostępu do konkretnego zasobu.

Zasada 1. Zawsze sprawdzaj: użytkownik + operacja + konkretny zasób.

Błąd 2: encja EF Core przyjmowana prosto z requestu (mass assignment)


Drugi błąd wygląda jak wygodne skrócenie kodu. Zamiast tworzyć osobny model requestu, przyjmujemy w kontrolerze bezpośrednio encję Entity Framework Core. Mniej klas, mniej mapowania - co może pójść nie tak?

Model. Encja dokumentu wygląda tak:

public sealed class Document
{
public Guid Id { get; set; }
public required string OwnerId { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public bool IsPublic { get; set; }
public bool IsApproved { get; set; }
}

Kod podatny. Endpoint do edycji dokumentu:

[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id,
Document document,
CancellationToken cancellationToken)
{
if (id != document.Id)
{
return BadRequest();
}

db.Documents.Update(document);
await db.SaveChangesAsync(cancellationToken);

return NoContent();
}

Formularz na froncie wysyła tylko tytuł i treść. Tyle że to, co wysyła formularz, nie ma żadnego znaczenia. Użytkownik może samodzielnie zmodyfikować request w narzędziach przeglądarki, w Postmanie czy w dowolnym kliencie HTTP.

Atak. Alice wysyła request z polami, których w formularzu w ogóle nie ma:

PUT https://localhost:7001/api/documents/11111111-1111-1111-1111-111111111111
Authorization: Bearer TOKEN_ALICE
Content-Type: application/json

{
"id": "11111111-1111-1111-1111-111111111111",
"ownerId": "user-2",
"title": "Zmieniony dokument",
"content": "Nowa treść",
"isPublic": true,
"isApproved": true
}

Backend przyjmuje całą encję i wykonuje Update, więc użytkownik może zmienić właściciela dokumentu, ustawić go jako publiczny albo sam go zatwierdzić. A przy okazji - jak w błędzie 1 - nikt nie sprawdza, czy edytowany dokument w ogóle należy do osoby, która wysyła request. Ta podatność nazywa się mass assignment (w dokumentacji Microsoftu znajdziesz ją też pod nazwą overposting). Wniosek jest prosty: ukrycie pola w interfejsie nie jest zabezpieczeniem.

Poprawka. Tworzymy osobne DTO, które zawiera wyłącznie pola, jakie użytkownik rzeczywiście może zmienić:

public sealed record UpdateDocumentRequest(
string Title,
string Content);

[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id,
UpdateDocumentRequest request,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
{
return Unauthorized();
}

var document = await db.Documents
.SingleOrDefaultAsync(
document =>
document.Id == id &&
document.OwnerId == userId,
cancellationToken);

if (document is null)
{
return NotFound();
}

document.Title = request.Title.Trim();
document.Content = request.Content;

await db.SaveChangesAsync(cancellationToken);

return NoContent();
}

Pobieramy istniejący dokument (z filtrem po właścicielu, jak w błędzie 1) i jawnie przypisujemy do niego tylko dozwolone wartości. Pola OwnerId, IsPublic i IsApproved są poza zasięgiem klienta, bo DTO po prostu ich nie ma. Nawet jeśli ktoś dopisze je do JSON-a, zostaną zignorowane przy deserializacji. Dokładnie takie podejście zaleca OWASP: DTO i allow-lista pól zamiast wiązania requestu bezpośrednio z obiektem domenowym.

Zasada 2. Nie przyjmuj encji domenowej z requestu. Użyj osobnego DTO i jawnego mapowania.

Błąd 3: sklejanie zapytań SQL w FromSqlRaw (SQL Injection)


Entity Framework Core chroni nas przed SQL Injection w wielu typowych sytuacjach, ale nie jest magiczną tarczą. Jego zabezpieczenia łatwo obejść, kiedy zaczniemy samodzielnie sklejać surowe zapytania SQL.

Kod podatny. Wyszukiwarka dokumentów po tytule:

[HttpGet("search")]
public async Task<ActionResult<IReadOnlyList<Document>>> Search(
string query,
CancellationToken cancellationToken)
{
var sql = $"""
SELECT *
FROM Documents
WHERE Title LIKE '%{query}%'
""";

var documents = await db.Documents
.FromSqlRaw(sql)
.AsNoTracking()
.ToListAsync(cancellationToken);

return Ok(documents);
}

Dane od użytkownika są wklejane bezpośrednio do tekstu zapytania, więc atakujący może spróbować zmienić znaczenie całego SQL-a. Zwróć uwagę na pułapkę: składnia $"..." wygląda niewinnie, ale interpolacja tekstu to nie parametryzacja. Do FromSqlRaw trafia gotowy string z wklejoną wartością.

Atak. Jako wyszukiwaną frazę wystarczy wpisać:

' OR 1=1 --

Warunek zamienia się w WHERE Title LIKE '%' OR 1=1 --%', czyli w warunek prawdziwy dla każdego wiersza, a reszta zapytania ląduje w komentarzu. Wynik: lista wszystkich dokumentów wszystkich użytkowników. To tylko lokalny, edukacyjny przykład, ale dokładnie pokazuje mechanizm: dane wejściowe zostały potraktowane jak fragment polecenia SQL. A skoro da się dopisać OR 1=1, da się dopisać też coś znacznie gorszego.

Najprostsza poprawka: LINQ. Jeśli możesz, po prostu użyj LINQ. EF Core sam zamieni wartości na parametry zapytania:

[HttpGet("search")]
public async Task<ActionResult<IReadOnlyList<DocumentResponse>>> Search(
string query,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
{
return Unauthorized();
}

var documents = await db.Documents
.AsNoTracking()
.Where(document =>
document.OwnerId == userId &&
document.Title.Contains(query))
.Select(document => new DocumentResponse(
document.Id,
document.Title,
document.Content))
.ToListAsync(cancellationToken);

return Ok(documents);
}

Przy okazji naprawiliśmy 2 inne rzeczy: wyszukiwarka zwraca tylko dokumenty zalogowanego użytkownika (lekcja z błędu 1) i oddaje DTO zamiast całych encji.

Gdy naprawdę potrzebujesz własnego SQL-a. Czasem LINQ nie wystarcza i zapytanie trzeba napisać ręcznie. Wtedy korzystaj z parametryzacji:

var pattern = $"%{query}%";

var documents = await db.Documents
.FromSql($"""
SELECT *
FROM Documents
WHERE OwnerId = {userId} AND Title LIKE {pattern}
""")
.AsNoTracking()
.ToListAsync(cancellationToken);

Wygląda prawie tak samo jak wersja podatna, ale różnica jest fundamentalna. FromSql (od EF Core 7) i starsze FromSqlInterpolated przyjmują FormattableString, więc wartości z nawiasów klamrowych trafiają do bazy jako parametry (np. @p0, @p1), a nie jako fragment tekstu zapytania. Co ważne, do FromSql nie przekażesz zwykłego stringa sklejonego wcześniej w zmiennej - kompilator na to nie pozwoli. FromSqlRaw natomiast może być podatne, jeżeli umieścimy w nim niezaufane dane. Jeśli już musisz go użyć, przekazuj wartości jako osobne parametry, zamiast sklejać je z tekstem zapytania.

Zasada 3. Dane użytkownika są wartością, a nie fragmentem polecenia SQL.

Błąd 4: MarkupString i wyłączone kodowanie HTML (XSS w Blazorze)


Czwarty błąd często jest dodawany zupełnie świadomie. Programista chce wyświetlić sformatowany opis, treść komentarza albo tekst z edytora WYSIWYG i widzi, że Blazor zamiast pogrubienia pokazuje znaczniki <strong> jako zwykły tekst. Dlatego wyłącza domyślne kodowanie, myśląc: "przecież ten HTML pochodzi z naszej bazy".

Kod podatny. Lista komentarzy w komponencie Blazora:

@foreach (var comment in comments)
{
<article class="comment">
@((MarkupString)comment.Content)
</article>
}

Atak. Wystarczy zapisać komentarz o takiej treści:

<strong>Ważna wiadomość</strong>
<img src="x" onerror="alert('XSS')">

Obrazek o adresie "x" nie istnieje, więc przeglądarka wywołuje onerror i wykonuje kod JavaScript - u każdego, kto wyświetli ten komentarz. Tutaj to tylko niewinny alert, ale w prawdziwym ataku w tym miejscu może być skrypt, który wykonuje akcje w imieniu ofiary albo wykrada dane widoczne na stronie.

Dane rzeczywiście zostały zapisane w naszej bazie, ale pierwotnie pochodzą od użytkownika. Baza danych nie zamienia niezaufanego tekstu w bezpieczny tekst. A rzutując treść na MarkupString, mówimy Blazorowi wprost: "ufam tej treści, wyrenderuj ją jako HTML".

Poprawka, gdy wystarczy zwykły tekst. Po prostu usuń rzutowanie:

@foreach (var comment in comments)
{
<article class="comment">
@comment.Content
</article>
}

Blazor i Razor domyślnie kodują tekst przed umieszczeniem go w HTML, więc ten sam komentarz wyświetli się jako niegroźny napis ze znacznikami. Najbezpieczniejsza poprawka to po prostu nie wyłączać tego mechanizmu.

Gdy biznes naprawdę wymaga HTML-a. Jeśli treść musi zawierać formatowanie, przed wyrenderowaniem przepuść ją przez sprawdzony sanitizer z allow-listą dozwolonych tagów i atrybutów (w .NET popularnym wyborem jest biblioteka HtmlSanitizer). Nie wystarczy usunięcie napisu "script" metodą Replace - jak widać w przykładzie wyżej, do wykonania kodu nie potrzeba nawet tagu <script>. Jako dodatkową warstwę ochrony warto skonfigurować Content Security Policy (CSP), która ogranicza, jakie skrypty przeglądarka w ogóle zgodzi się uruchomić. Pamiętaj też, że ochrona przed XSS zależy od kontekstu, w którym umieszczasz dane: inaczej koduje się wartość w treści HTML, inaczej w atrybucie, a jeszcze inaczej w JavaScripcie czy w URL-u.

Zasada 4. Nie wyłączaj kodowania HTML dla treści pochodzących od użytkownika.

Błąd 5: backend pobiera dowolny URL od użytkownika (SSRF)


Załóżmy, że aplikacja ma funkcję podglądu linku: użytkownik podaje URL, a backend pobiera zawartość strony. To popularny mechanizm - podobnie działa generowanie miniaturek, import plików z adresu, webhooki czy integracje z zewnętrznymi API.

Kod podatny:

public sealed record PreviewRequest(string Url);

[HttpPost("preview")]
public async Task<IActionResult> Preview(
PreviewRequest request,
[FromServices] HttpClient httpClient,
CancellationToken cancellationToken)
{
var content = await httpClient.GetStringAsync(
request.Url,
cancellationToken);

return Content(content, "text/plain");
}

Atak. Z publicznym adresem wszystko działa zgodnie z planem. Ale co się stanie, jeśli zamiast niego podamy adres wewnętrznego endpointu administracyjnego?

POST https://localhost:7001/api/documents/preview
Authorization: Bearer TOKEN_ALICE
Content-Type: application/json

{
"url": "http://localhost:7001/internal/configuration"
}

Kluczowe jest to, kto wysyła żądanie. Nie przeglądarka użytkownika, tylko nasz serwer - a serwer często ma dostęp do rzeczy niewidocznych z Internetu: localhosta, sieci wewnętrznej, paneli i usług administracyjnych. Atakujący używa więc naszej aplikacji jako pośrednika, który wpuszcza go tam, gdzie sam by nie dotarł.

To jest SSRF, czyli Server-Side Request Forgery. Problem powstaje zawsze, gdy aplikacja pobiera zasób wskazany przez użytkownika bez odpowiedniej kontroli adresu.

Poprawka: ścisła allow-lista. Najlepszym zabezpieczeniem jest lista adresów, z którymi aplikacja rzeczywiście musi się komunikować. Zamykamy to w osobnym, typowanym kliencie HTTP:

public sealed class SafePreviewClient(HttpClient httpClient)
{
private static readonly HashSet<string> AllowedHosts =
new(StringComparer.OrdinalIgnoreCase)
{
"api.nbp.pl",
"images.example.com"
};

public async Task<string> GetAsync(
string rawUrl,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
{
throw new ArgumentException("Niepoprawny adres.");
}

if (uri.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Dozwolony jest tylko HTTPS.");
}

if (!AllowedHosts.Contains(uri.IdnHost))
{
throw new ArgumentException("Niedozwolony host.");
}

using var response = await httpClient.GetAsync(
uri,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);

response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync(cancellationToken);
}
}

Adres musi dać się sparsować, musi używać HTTPS, a host musi być na liście. Wszystko inne odrzucamy. W kontrolerze wstrzykujesz SafePreviewClient zamiast gołego HttpClient, a ArgumentException zamieniasz na odpowiedź 400 Bad Request.

Konfiguracja klienta. Klienta rejestrujemy z krótkim timeoutem i wyłączonymi automatycznymi przekierowaniami:

builder.Services
.AddHttpClient<SafePreviewClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
})
.ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler
{
AllowAutoRedirect = false
});

Dlaczego bez przekierowań? Bo dozwolony adres mógłby odpowiedzieć przekierowaniem w zupełnie inne miejsce - i cała allow-lista na nic. W rozwiązaniu produkcyjnym warto pójść krok dalej: ograniczyć ruch wychodzący na poziomie sieci, kontrolować rozmiar odpowiedzi i trzymać krótki timeout.

I jeszcze jedno: samo sprawdzenie, czy adres nie zawiera słowa "localhost", nie wystarczy. Ten sam serwer da się wskazać na wiele innych sposobów, np. przez 127.0.0.1, adres IPv6 ::1 albo domenę, która wskazuje na adres wewnętrzny. Dlatego allow-lista (co wolno), a nie block-lista (czego nie wolno).

Zasada 5. Nie pozwalaj użytkownikowi decydować, dokąd Twój serwer wysyła żądania.

Błąd 6: sekrety w appsettings.json i w repozytorium


Szósty błąd jest banalny, ale wciąż bardzo niebezpieczny. Klucz do podpisywania tokenów JWT, hasło do bazy, API key albo connection string trafiają do appsettings.json, a stamtąd prosto do repozytorium.

Podatny przykład:

{
"ConnectionStrings": {
"Database": "Server=prod;Database=SecureDocs;User Id=admin;Password=P@ssword123"
},
"Jwt": {
"SigningKey": "this-is-my-production-signing-key"
}
}

Droga sekretu wygląda potem tak: appsettings.json → Git → GitHub → wszystkie kopie repozytorium. Każdy, kto ma dostęp do repo (dziś albo za 3 lata), ma też dostęp do produkcyjnej bazy. A klucz JWT w niepowołanych rękach oznacza możliwość wystawienia sobie tokena dowolnego użytkownika - także administratora.

.gitignore po fakcie nie pomaga. Dodanie pliku do .gitignore po wykonaniu commita nie rozwiązuje problemu. Sekret nadal istnieje w historii Gita, w kopiach repozytorium i na komputerach innych programistów. Dlatego zasada jest twarda: jeśli sekret trafił do repozytorium, traktuj go jako ujawniony i go zmień (wygeneruj nowy klucz, zmień hasło). Samo czyszczenie historii to za mało.

Development: User Secrets. Na lokalnej maszynie trzymaj sekrety w User Secrets, czyli poza katalogiem projektu:

dotnet user-secrets init

dotnet user-secrets set "Jwt:SigningKey" "lokalny-klucz-tylko-do-developmentu"

dotnet user-secrets set "ConnectionStrings:Database" "Server=localhost;Database=SecureDocs;Trusted_Connection=True"

W kodzie nic się nie zmienia - wartość odczytujesz z konfiguracji jak zwykle. Warto tylko, żeby aplikacja głośno zgłaszała brak klucza, zamiast po cichu startować z pustą wartością:

var signingKey =
builder.Configuration["Jwt:SigningKey"]
?? throw new InvalidOperationException(
"Brak konfiguracji Jwt:SigningKey.");

Produkcja: magazyn sekretów. User Secrets pomagają w środowisku deweloperskim, ale nie są sejfem produkcyjnym - to zwykły, niezaszyfrowany plik JSON w profilu użytkownika. Na produkcji wykorzystaj kontrolowany magazyn sekretów, np. Azure Key Vault, albo bezpieczną konfigurację dostarczaną przez środowisko wdrożeniowe (np. zmienne środowiskowe - klucz Jwt:SigningKey zapisujesz wtedy jako Jwt__SigningKey, z podwójnym podkreślnikiem). Dokumentacja Microsoftu wprost odradza przechowywanie haseł i innych danych poufnych w kodzie oraz w plikach konfiguracyjnych.

Zasada 6. Sekrety trzymaj poza kodem, poza repozytorium i oddzielnie dla każdego środowiska.

Błąd 7: endpoint logowania bez limitów (rate limiting i blokada konta)


Ostatni błąd często wychodzi dopiero po wdrożeniu. Endpoint logowania działa poprawnie, ale można go wywołać tysiące razy z rzędu: bez limitu, bez blokady konta i bez monitorowania. Atakujący może wtedy automatycznie zgadywać hasła, testować pary login-hasło z wcześniejszych wycieków (credential stuffing) albo po prostu generować kosztowne operacje po stronie serwera - weryfikacja hasła jest przecież celowo kosztowna obliczeniowo.

Rate limiting w ASP.NET Core. Od .NET 7 mamy wbudowany middleware do ograniczania liczby żądań. Definiujemy politykę "login": maksymalnie 5 prób na minutę z jednego adresu IP:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

options.AddPolicy("login", httpContext =>
{
var partitionKey =
httpContext.Connection.RemoteIpAddress?.ToString()
?? "unknown";

return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});

Zwróć uwagę na RejectionStatusCode. Domyślnie middleware odrzuca nadmiarowe żądania kodem 503 Service Unavailable, a właściwą odpowiedzią w tej sytuacji jest 429 Too Many Requests.

Middleware dodajemy do pipeline'u - koniecznie po UseRouting:

var app = builder.Build();

app.UseHttpsRedirection();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapControllers();

app.Run();

A sam endpoint logowania oznaczamy atrybutem [EnableRateLimiting] z nazwą polityki:

[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
[HttpPost("login")]
[EnableRateLimiting("login")]
public async Task<IActionResult> Login(
LoginRequest request,
CancellationToken cancellationToken)
{
/* weryfikacja danych logowania */
return Ok();
}
}

Efekt? Jeśli wyślesz serię żądań (z pliku .http, z Postmana albo prostym skryptem), pierwsze 5 dotrze do endpointu, a kolejne dostaną 429 Too Many Requests, dopóki nie minie minuta.

Uwaga na reverse proxy. Jeśli aplikacja stoi za reverse proxy albo load balancerem, Connection.RemoteIpAddress może zwracać adres proxy, a nie użytkownika. Bez skonfigurowanego middleware'u ForwardedHeaders wszyscy użytkownicy wpadną wtedy do jednego wspólnego limitu - i po 5 próbach w danej minucie nikt już się nie zaloguje.

Blokada konta w ASP.NET Core Identity. Rate limiting nie powinien być jedyną ochroną. Jeśli korzystasz z ASP.NET Core Identity, włącz też blokadę konta po serii nieudanych logowań:

builder.Services.Configure<IdentityOptions>(options =>
{
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
});

Pułapka, na którą łatwo się złapać: sama konfiguracja nie wystarczy. Blokada zadziała tylko wtedy, gdy przy logowaniu wywołujesz PasswordSignInAsync (albo CheckPasswordSignInAsync) z parametrem lockoutOnFailure: true. W kodzie generowanym z szablonów Identity domyślnie jest tam false. Do tego dołóż rejestrowanie nieudanych logowań, wymaganie silnych haseł i uwierzytelnianie wieloskładnikowe (MFA).

Limity trzeba dopasować do aplikacji. Limitowanie wyłącznie po adresie IP nie jest idealne. Z jednej strony w jednej sieci (np. firmowej) może siedzieć wielu prawdziwych użytkowników, a z drugiej atakujący może korzystać z wielu adresów naraz. Dlatego mechanizm trzeba dopasować do konkretnej aplikacji i przetestować pod obciążeniem. Microsoft zaznacza też, że rate limiting pomaga ograniczać nadużycia i przeciążenie, ale sam w sobie nie stanowi kompletnej ochrony przed rozproszonym atakiem DDoS.

Zasada 7. Szczególnie chroń: logowanie, reset hasła, rejestrację, wysyłkę wiadomości i wszystkie kosztowne endpointy.

Bonus: 2 błędy, które utrudniają wykrycie ataku


Dobry system bezpieczeństwa nie tylko zapobiega atakom. Musi też pozwolić je wykryć i zareagować. Dlatego na koniec 2 krótkie bonusy, które dotyczą właśnie tej drugiej części.

Bonus 1: szczegóły błędów wysyłane do klienta. Nie wysyłaj klientowi stack trace'a, connection stringa ani szczegółów wewnętrznych aplikacji. Dla atakującego to darmowa mapa systemu: nazwy klas, struktura bazy, ścieżki na serwerze. W produkcji klient powinien dostać bezpieczny, ogólny komunikat, a szczegóły błędu mają zostać w logach i w systemie monitorowania:

builder.Services.AddProblemDetails();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
}

app.UseStatusCodePages();

AddProblemDetails sprawia, że błędy wracają w ustandaryzowanym formacie Problem Details. UseExceptionHandler poza środowiskiem deweloperskim przechwytuje wyjątki i zamienia je na ogólną odpowiedź 500 bez szczegółów, a UseStatusCodePages dodaje taką samą, ogólną treść do odpowiedzi z kodami błędów, które nie mają body (np. 404). ASP.NET Core wprost odradza udostępnianie szczegółowych informacji o wyjątkach na produkcji.

Bonus 2: wrażliwe dane w logach. Loguj zdarzenia związane z bezpieczeństwem, ale nie zapisuj w logach haseł, tokenów ani pełnych danych poufnych. Zły log wygląda tak:

logger.LogInformation(
"Login: {Email}, password: {Password}, token: {Token}",
request.Email,
request.Password,
accessToken);

Logi czyta zwykle znacznie więcej osób i systemów niż bazę produkcyjną: trafiają do narzędzi monitorujących, kopii zapasowych i zgłoszeń do supportu. Hasło w logu to hasło wyniesione poza wszystkie zabezpieczenia. Lepszy log zapisuje fakt, a nie sekrety:

logger.LogWarning(
"Nieudane logowanie dla konta {AccountId} z adresu {IpAddress}",
accountId,
ipAddress);

Taki wpis pozwala wykryć atak (np. setki nieudanych logowań z jednego adresu) i na niego zareagować, a przy tym niczego nie ujawnia. OWASP zaleca, aby w logach nie umieszczać m.in. haseł, access tokenów, connection stringów i kluczy szyfrujących.

Ściąga: 7 pytań do code review


Wszystkie 7 błędów da się zamienić w 7 pytań, które warto zadawać przy każdym code review. Zapisz sobie tę tabelę i sprawdź pod jej kątem jeden ze swoich projektów - najlepiej jeszcze dziś:

Pytanie do code reviewBłąd (podatność)Jak naprawić
1. Czy sprawdzamy właściciela zasobu?[Authorize] bez sprawdzania właściciela (IDOR)filtr po właścicielu w zapytaniu, 404 zamiast 403, resource-based authorization
2. Czy request używa osobnego DTO?encja EF Core prosto z requestu (mass assignment)DTO tylko z dozwolonymi polami + jawne mapowanie
3. Czy zapytania SQL są parametryzowane?sklejanie SQL-a w FromSqlRaw (SQL Injection)LINQ albo FromSql / FromSqlInterpolated
4. Czy nie renderujemy niezaufanego HTML-a?MarkupString na treści od użytkownika (XSS)domyślne kodowanie, sanitizer z allow-listą, CSP
5. Czy kontrolujemy połączenia wychodzące?pobieranie dowolnego URL-a (SSRF)allow-lista hostów, tylko HTTPS, bez przekierowań, krótki timeout
6. Czy sekrety są poza repozytorium?sekrety w appsettings.jsonUser Secrets w developmencie, magazyn sekretów na produkcji, zmiana ujawnionych sekretów
7. Czy wrażliwe endpointy mają limity?logowanie bez limitów (brute force)rate limiting (429), blokada konta, MFA


Podsumowanie


HTTPS, EF Core i [Authorize] to dobry start, ale nie gwarancja bezpieczeństwa. Zbierzmy najważniejsze zasady w jednym miejscu:

• Nie wystarczy [Authorize] - kontroluj dostęp do konkretnego rekordu.
• Nie przyjmuj encji domenowych z requestu - osobne DTO i jawne mapowanie.
• Nie sklejaj SQL-a z danych użytkownika - LINQ albo parametryzacja.
• Nie wyłączaj kodowania HTML bez bardzo dobrego powodu, a jeśli musisz - sanitizer.
• Kontroluj adresy, z którymi łączy się Twój backend - allow-lista zamiast block-listy.
• Trzymaj sekrety poza repozytorium, a ujawnione od razu zmieniaj.
• Ograniczaj liczbę żądań do najbardziej wrażliwych endpointów.

Do tego 2 bonusy: nie pokazuj klientowi szczegółów błędów i nie zapisuj sekretów w logach.

Te 7 punktów to jednak dopiero początek. Bezpieczeństwo aplikacji obejmuje też poprawne uwierzytelnianie i autoryzację, JWT, cookies, CSRF, ochronę przed brute force, bezpieczne przechowywanie danych, konfigurację CORS, nagłówki bezpieczeństwa, kryptografię, logowanie, monitoring i testowanie zabezpieczeń. Jeśli chcesz to ogarnąć po kolei, zamiast sklejać wiedzę z losowych artykułów, mam dla Ciebie coś konkretnego.

Prowadzę Szkołę Bezpieczeństwa w C#/.NET - praktyczne szkolenie online dla programistów C# i .NET, w którym krok po kroku zabezpieczamy aplikację opartą o SPA i ASP.NET Core Web API. Nie skupiamy się wyłącznie na teorii: pokazuję konkretne ataki, podatny kod i sposób jego poprawienia - tak jak w tym artykule, tylko dużo szerzej. W programie są m.in. uwierzytelnianie i autoryzacja, JWT, cookies, SQL Injection, XSS, CSRF, SSRF, rate limiting, kryptografia, logowanie, nagłówki bezpieczeństwa i testy.

Program, szczegóły i najbliższy termin zapisów znajdziesz tutaj: modestprogrammer.pl/b.

Sprawdź swój kod, zanim zrobi to ktoś inny :)

Autor artykułu:
Kazimierz Szpin
Kazimierz Szpin
CTO & Founder - FindSolution.pl
Programista C#/.NET. Specjalizuje się w Blazor, ASP.NET Core, ASP.NET MVC, ASP.NET Web API, WPF oraz Windows Forms.
Autor bloga ModestProgrammer.pl
Dodaj komentarz
© Copyright 2026 modestprogrammer.pl | Sztuczna Inteligencja | Regulamin | Polityka prywatności. Design by Kazimierz Szpin. Wszelkie prawa zastrzeżone.