OWASP Top 10 — список десяти наиболее критичных категорий уязвимостей веб-приложений. Обновляется каждые 3-4 года на основе данных из реальных приложений.
A01: Broken Access Control
Нарушение контроля доступа — пользователь может выполнять действия за пределами своих прав.
<?php
declare(strict_types=1);
// VULNERABLE: Direct Object Reference without authorization check
final class OrderControllerVulnerable
{
public function show(int $orderId): array
{
// Any authenticated user can view ANY order
return $this->repository->findById($orderId);
}
}
// SECURE: Check ownership before returning data
final readonly class OrderControllerSecure
{
public function __construct(
private OrderRepository $repository,
private SecurityContext $security,
) {}
public function show(int $orderId): array
{
$order = $this->repository->findById($orderId);
if ($order === null) {
// Handle "missing" before "not yours": dereferencing null here
// would surface a stack trace instead of a clean 404
throw new NotFoundException('Order not found.');
}
$currentUser = $this->security->getCurrentUser();
if ($order->getUserId() !== $currentUser->getId()
&& !$currentUser->hasRole('ROLE_ADMIN')
) {
throw new AccessDeniedException('You cannot view this order.');
}
return $order->toArray();
}
}
package api
import (
"errors"
"net/http"
)
var (
ErrOrderNotFound = errors.New("order not found")
ErrForbidden = errors.New("you cannot view this order")
)
// VULNERABLE: returns any order without authorization check.
// func (h *Handler) ShowVulnerable(orderID int) (*Order, error) {
// return h.repo.FindByID(orderID) // Any user can view ANY order
// }
// SECURE: check ownership before returning data.
func (h *Handler) Show(r *http.Request, orderID int) (*Order, error) {
order, err := h.repo.FindByID(r.Context(), orderID)
if err != nil {
return nil, err
}
// Handle "missing" before "not yours": a nil order would panic below.
if order == nil {
return nil, ErrOrderNotFound
}
currentUser := UserFromContext(r.Context())
if order.UserID != currentUser.ID && !currentUser.HasRole("admin") {
return nil, ErrForbidden
}
return order, nil
}
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace App.Api;
// VULNERABLE: direct object reference without an authorization check.
// [HttpGet("/orders/{orderId:int}")]
// public async Task<Order?> ShowVulnerable(int orderId)
// => await repository.FindByIdAsync(orderId); // Any user can view ANY order
// SECURE: check ownership before returning data.
[ApiController]
[Authorize]
public sealed class OrderController(IOrderRepository repository) : ControllerBase
{
[HttpGet("/orders/{orderId:int}")]
public async Task<IActionResult> Show(int orderId, CancellationToken ct)
{
Order? order = await repository.FindByIdAsync(orderId, ct);
if (order is null)
{
return NotFound();
}
string? currentUserId = User.FindFirst("sub")?.Value;
if (order.UserId != currentUserId && !User.IsInRole("Admin"))
{
return Forbid();
}
return Ok(order);
}
}
from fastapi import APIRouter, Depends, HTTPException, status
router = APIRouter()
# VULNERABLE: direct object reference without an authorization check.
# @router.get("/orders/{order_id}")
# async def show_vulnerable(order_id: int) -> Order:
# return await repository.find_by_id(order_id) # Any user can view ANY order
# SECURE: check ownership before returning data.
@router.get("/orders/{order_id}")
async def show(
order_id: int,
repository: OrderRepository = Depends(get_order_repository),
current_user: User = Depends(get_current_user),
) -> Order:
order = await repository.find_by_id(order_id)
if order is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if order.user_id != current_user.id and "admin" not in current_user.roles:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail="You cannot view this order.",
)
return order
## A02: Cryptographic Failures
Неправильное использование криптографии или её отсутствие.
<?php
declare(strict_types=1);
// VULNERABLE: Weak hashing, sensitive data in plain text
final class UserServiceVulnerable
{
public function register(string $email, string $password): void
{
$hash = md5($password); // MD5 is broken
// Storing credit card without encryption
$this->db->insert('users', [
'email' => $email,
'password' => $hash,
]);
}
}
// SECURE: Strong hashing, proper encryption
final readonly class UserServiceSecure
{
public function __construct(
private Connection $db,
private EncryptionService $encryption,
) {}
public function register(string $email, string $password): void
{
$hash = password_hash($password, PASSWORD_ARGON2ID);
$this->db->insert('users', [
'email' => $email,
'password' => $hash,
]);
}
public function storeSensitiveData(int $userId, string $ssn): void
{
// Encrypt sensitive data at rest
$encrypted = $this->encryption->encrypt($ssn);
$this->db->update('users', ['ssn_encrypted' => $encrypted], ['id' => $userId]);
}
}
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// VULNERABLE: weak hashing
// hash := md5.Sum([]byte(password)) // MD5 is broken!
// SECURE: strong hashing with Argon2id.
const (
argonTime = 4
argonMemory = 64 * 1024 // 64 MiB
argonThreads = 3
argonKeyLen = 32
argonSaltLen = 16
)
// HashPassword derives an Argon2id hash with a freshly generated salt.
// The salt is created here rather than accepted from the caller: a reused or
// caller-chosen salt makes precomputed rainbow tables viable again.
func HashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
// Store the parameters and salt next to the digest so that verification
// stays possible after the cost parameters are raised.
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches the stored Argon2id hash.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported password hash format")
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, fmt.Errorf("parse hash parameters: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode digest: %w", err)
}
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
// Constant-time compare: a plain bytes.Equal returns early on the first
// differing byte, which leaks the digest one byte at a time via timing.
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
using Microsoft.AspNetCore.Identity;
using Npgsql;
namespace App.Auth;
// VULNERABLE: weak hashing, sensitive data stored in plain text.
// string hash = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(password))); // MD5 is broken!
// SECURE: strong hashing via Identity's PasswordHasher (PBKDF2-HMAC-SHA512,
// 100k iterations by default), plus encryption at rest for sensitive fields.
public sealed class UserService(
NpgsqlDataSource dataSource,
IPasswordHasher<User> passwordHasher,
IEncryptionService encryption)
{
public async Task RegisterAsync(string email, string password, CancellationToken ct = default)
{
var user = new User { Email = email };
string hash = passwordHasher.HashPassword(user, password);
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"INSERT INTO users (email, password) VALUES (@email, @password)");
cmd.Parameters.AddWithValue("email", email);
cmd.Parameters.AddWithValue("password", hash);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task StoreSensitiveDataAsync(int userId, string ssn, CancellationToken ct = default)
{
// Encrypt sensitive data at rest.
string encrypted = encryption.Encrypt(ssn);
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"UPDATE users SET ssn_encrypted = @ssn WHERE id = @id");
cmd.Parameters.AddWithValue("ssn", encrypted);
cmd.Parameters.AddWithValue("id", userId);
await cmd.ExecuteNonQueryAsync(ct);
}
}
import psycopg
from argon2 import PasswordHasher
# VULNERABLE: weak hashing, sensitive data stored in plain text.
# password_hash = hashlib.md5(password.encode()).hexdigest() # MD5 is broken!
# SECURE: Argon2id hashing plus encryption at rest for sensitive fields.
_hasher = PasswordHasher(memory_cost=65536, time_cost=4, parallelism=3)
class UserService:
def __init__(self, conn: psycopg.AsyncConnection, encryption: EncryptionService) -> None:
self._conn = conn
self._encryption = encryption
async def register(self, email: str, password: str) -> None:
password_hash = _hasher.hash(password)
async with self._conn.cursor() as cur:
await cur.execute(
"INSERT INTO users (email, password) VALUES (%s, %s)",
(email, password_hash),
)
async def store_sensitive_data(self, user_id: int, ssn: str) -> None:
# Encrypt sensitive data at rest.
encrypted = self._encryption.encrypt(ssn)
async with self._conn.cursor() as cur:
await cur.execute(
"UPDATE users SET ssn_encrypted = %s WHERE id = %s",
(encrypted, user_id),
)
## A03: Injection
Внедрение вредоносного кода через пользовательский ввод.
SQL Injection
<?php
declare(strict_types=1);
// VULNERABLE: String concatenation in SQL
final class SearchVulnerable
{
public function findUsers(string $name): array
{
// Attacker can input: ' OR '1'='1
$sql = "SELECT * FROM users WHERE name = '{$name}'";
return $this->db->query($sql)->fetchAll();
}
}
// SECURE: Parameterized queries
final readonly class SearchSecure
{
public function __construct(
private Connection $db,
) {}
public function findUsers(string $name): array
{
return $this->db->fetchAllAssociative(
'SELECT id, name, email FROM users WHERE name = :name',
['name' => $name],
);
}
}
package repository
import (
"context"
"database/sql"
)
// VULNERABLE: string concatenation in SQL
// query := "SELECT * FROM users WHERE name = '" + name + "'"
// SECURE: parameterized queries
func (r *Repo) FindUsers(ctx context.Context, name string) ([]User, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, name, email FROM users WHERE name = $1", name)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
using Npgsql;
namespace App.Repository;
// VULNERABLE: string concatenation in SQL.
// string sql = $"SELECT * FROM users WHERE name = '{name}'";
// Attacker can input: ' OR '1'='1
// SECURE: parameterized queries.
public sealed class UserRepository(NpgsqlDataSource dataSource)
{
public async Task<IReadOnlyList<User>> FindUsersAsync(string name, CancellationToken ct = default)
{
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"SELECT id, name, email FROM users WHERE name = @name");
cmd.Parameters.AddWithValue("name", name);
var users = new List<User>();
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
users.Add(new User(
Id: reader.GetInt32(0),
Name: reader.GetString(1),
Email: reader.GetString(2)));
}
return users;
}
}
// With EF Core, LINQ queries are parameterized automatically:
// await db.Users.Where(u => u.Name == name).ToListAsync(ct);
// FromSqlRaw with an interpolated string is NOT — use FromSql instead.
import psycopg
from psycopg.rows import class_row
# VULNERABLE: string concatenation in SQL.
# sql = f"SELECT * FROM users WHERE name = '{name}'"
# Attacker can input: ' OR '1'='1
class UserRepository:
def __init__(self, conn: psycopg.AsyncConnection) -> None:
self._conn = conn
async def find_users(self, name: str) -> list[User]:
"""SECURE: parameterized query — %s is a placeholder, not string formatting."""
async with self._conn.cursor(row_factory=class_row(User)) as cur:
await cur.execute(
"SELECT id, name, email FROM users WHERE name = %s",
(name,),
)
return await cur.fetchall()
### Command Injection
<?php
declare(strict_types=1);
// VULNERABLE: Unsanitized input in shell command
function resizeImageVulnerable(string $filename, int $width): void
{
// Attacker: filename = "img.jpg; rm -rf /"
exec("convert {$filename} -resize {$width}x output.jpg");
}
// SECURE: Validate input, then pass arguments as an array — no shell involved
function resizeImageSecure(string $filename, int $width): void
{
// Allow-list the filename: anything outside this pattern is rejected
if (!preg_match('/^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$/i', $filename)) {
throw new \InvalidArgumentException('Invalid filename');
}
// An array command makes proc_open bypass the shell entirely, so ";",
// "$(...)" and friends are passed through as literal argument text.
// escapeshellarg + a string command still runs a shell, and one missed
// escape is enough to lose the machine
$process = proc_open(
['convert', $filename, '-resize', "{$width}x", 'output.jpg'],
[],
$pipes,
);
if ($process === false) {
throw new \RuntimeException('Failed to start convert');
}
$exitCode = proc_close($process);
if ($exitCode !== 0) {
throw new \RuntimeException("convert exited with code {$exitCode}");
}
}
package image
import (
"fmt"
"os/exec"
"regexp"
)
var safeFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$`)
// VULNERABLE: unsanitized input in shell command
// exec.Command("sh", "-c", fmt.Sprintf("convert %s -resize %dx output.jpg", filename, width))
// SECURE: validate input and use exec.Command with separate args
func ResizeImage(filename string, width int) error {
if !safeFilename.MatchString(filename) {
return fmt.Errorf("invalid filename: %s", filename)
}
// exec.Command does NOT use a shell -- no injection possible
cmd := exec.Command("convert", filename, "-resize",
fmt.Sprintf("%dx", width), "output.jpg")
return cmd.Run()
}
using System.Diagnostics;
using System.Text.RegularExpressions;
namespace App.Imaging;
public static partial class ImageResizer
{
[GeneratedRegex(@"^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$", RegexOptions.IgnoreCase)]
private static partial Regex SafeFilename();
// VULNERABLE: unsanitized input passed to a shell.
// Process.Start("/bin/sh", $"-c \"convert {filename} -resize {width}x output.jpg\"");
// Attacker: filename = "img.jpg; rm -rf /"
// SECURE: validate input, then pass arguments separately — no shell involved.
public static async Task ResizeAsync(string filename, int width, CancellationToken ct = default)
{
if (!SafeFilename().IsMatch(filename))
{
throw new ArgumentException("Invalid filename", nameof(filename));
}
var startInfo = new ProcessStartInfo
{
FileName = "convert",
UseShellExecute = false,
};
// ArgumentList escapes each item; no shell metacharacter is ever interpreted.
startInfo.ArgumentList.Add(filename);
startInfo.ArgumentList.Add("-resize");
startInfo.ArgumentList.Add($"{width}x");
startInfo.ArgumentList.Add("output.jpg");
using Process process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start convert");
await process.WaitForExitAsync(ct);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"convert exited with code {process.ExitCode}");
}
}
}
import asyncio
import re
SAFE_FILENAME = re.compile(r"^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$", re.IGNORECASE)
# VULNERABLE: unsanitized input passed to a shell.
# subprocess.run(f"convert {filename} -resize {width}x output.jpg", shell=True)
# Attacker: filename = "img.jpg; rm -rf /"
async def resize_image(filename: str, width: int) -> None:
"""SECURE: validate input, pass arguments as a list — no shell involved."""
if not SAFE_FILENAME.match(filename):
raise ValueError("invalid filename")
# create_subprocess_exec never spawns a shell, so metacharacters are inert.
process = await asyncio.create_subprocess_exec(
"convert",
filename,
"-resize",
f"{width}x",
"output.jpg",
)
return_code = await process.wait()
if return_code != 0:
raise RuntimeError(f"convert exited with code {return_code}")
## A04: Insecure Design
Недостатки в архитектуре, которые нельзя исправить одной реализацией.
<?php
declare(strict_types=1);
// INSECURE DESIGN: No rate limiting on password reset
final class PasswordResetVulnerable
{
public function requestReset(string $email): void
{
$code = random_int(1000, 9999); // 4-digit code — brute-forceable
$this->sendResetCode($email, $code);
}
}
// SECURE DESIGN: Rate limiting + strong token
final readonly class PasswordResetSecure
{
public function __construct(
private RateLimiter $limiter,
private TokenGenerator $tokens,
private Mailer $mailer,
) {}
public function requestReset(string $email): void
{
// Rate limit: max 3 requests per hour per email
if (!$this->limiter->attempt("password_reset:{$email}", maxAttempts: 3, perSeconds: 3600)) {
throw new TooManyRequestsException('Too many reset requests. Try again later.');
}
// Generate a long, unguessable token (not a 4-digit PIN)
$token = $this->tokens->generate(length: 32);
$this->storeToken($email, $token, expiresInMinutes: 30);
$this->mailer->sendResetLink($email, $token);
}
}
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// VULNERABLE: 4-digit code -- brute-forceable
// code := rand.Intn(9000) + 1000
// SECURE: rate limiting + strong token
type PasswordResetService struct {
limiter RateLimiter
mailer Mailer
store TokenStore
}
func (s *PasswordResetService) RequestReset(email string) error {
// Rate limit: max 3 requests per hour per email
if !s.limiter.Attempt(fmt.Sprintf("password_reset:%s", email), 3, 3600) {
return ErrTooManyRequests
}
// Generate a long, unguessable token
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return err
}
token := hex.EncodeToString(b)
// A dropped error here would mail a reset link that was never persisted,
// or worse, leave an unexpired token behind.
if err := s.store.Save(email, token, 30*60); err != nil { // 30 min TTL
return fmt.Errorf("store reset token: %w", err)
}
return s.mailer.SendResetLink(email, token)
}
using System.Security.Cryptography;
namespace App.Auth;
// INSECURE DESIGN: a 4-digit code is brute-forceable in seconds.
// int code = RandomNumberGenerator.GetInt32(1000, 10000);
// SECURE DESIGN: rate limiting + a long, unguessable token.
public sealed class PasswordResetService(
IRateLimiter limiter,
ITokenStore tokens,
IMailer mailer)
{
private const int MaxRequestsPerWindow = 3;
private const int TokenBytes = 32;
private static readonly TimeSpan RequestWindow = TimeSpan.FromHours(1);
private static readonly TimeSpan TokenTtl = TimeSpan.FromMinutes(30);
public async Task RequestResetAsync(string email, CancellationToken ct = default)
{
if (!await limiter.AttemptAsync($"password_reset:{email}", MaxRequestsPerWindow, RequestWindow, ct))
{
throw new TooManyRequestsException("Too many reset requests. Try again later.");
}
// 256 bits of entropy — not a 4-digit PIN.
string token = Convert.ToHexString(RandomNumberGenerator.GetBytes(TokenBytes))
.ToLowerInvariant();
await tokens.SaveAsync(email, token, TokenTtl, ct);
await mailer.SendResetLinkAsync(email, token, ct);
}
}
import secrets
from datetime import timedelta
MAX_REQUESTS_PER_WINDOW = 3
REQUEST_WINDOW = timedelta(hours=1)
TOKEN_TTL = timedelta(minutes=30)
TOKEN_BYTES = 32
# INSECURE DESIGN: a 4-digit code is brute-forceable in seconds.
# code = secrets.randbelow(9000) + 1000
class TooManyRequestsError(Exception):
"""Raised when the reset request rate limit is exceeded."""
class PasswordResetService:
"""SECURE DESIGN: rate limiting + a long, unguessable token."""
def __init__(self, limiter: RateLimiter, tokens: TokenStore, mailer: Mailer) -> None:
self._limiter = limiter
self._tokens = tokens
self._mailer = mailer
async def request_reset(self, email: str) -> None:
allowed = await self._limiter.attempt(
f"password_reset:{email}",
max_attempts=MAX_REQUESTS_PER_WINDOW,
window=REQUEST_WINDOW,
)
if not allowed:
raise TooManyRequestsError("Too many reset requests. Try again later.")
# 256 bits of entropy — not a 4-digit PIN.
token = secrets.token_hex(TOKEN_BYTES)
await self._tokens.save(email, token, ttl=TOKEN_TTL)
await self._mailer.send_reset_link(email, token)
## A05: Security Misconfiguration
Неправильные настройки безопасности серверов, фреймворков, приложений.
<?php
declare(strict_types=1);
// VULNERABLE: Debug mode in production, verbose errors
// .env: APP_DEBUG=true, APP_ENV=dev
// SECURE: Production hardening checklist
final readonly class SecurityConfigChecker
{
/**
* @return array<string, array{status: string, message: string}>
*/
public function check(): array
{
$checks = [];
// Debug mode. A missing variable must never read as "secure",
// so the default is the unsafe value and the check fails closed
$checks['debug_mode'] = [
'status' => ($_ENV['APP_DEBUG'] ?? 'true') === 'false' ? 'pass' : 'fail',
'message' => 'Debug mode must be disabled in production',
];
// Error display
$checks['display_errors'] = [
'status' => ini_get('display_errors') === '0' ? 'pass' : 'fail',
'message' => 'display_errors must be Off',
];
// Expose PHP
$checks['expose_php'] = [
'status' => ini_get('expose_php') === '0' ? 'pass' : 'fail',
'message' => 'expose_php must be Off (hides X-Powered-By header)',
];
// Session security
$checks['session_cookie_secure'] = [
'status' => ini_get('session.cookie_secure') === '1' ? 'pass' : 'fail',
'message' => 'session.cookie_secure must be On for HTTPS',
];
$checks['session_cookie_httponly'] = [
'status' => ini_get('session.cookie_httponly') === '1' ? 'pass' : 'fail',
'message' => 'session.cookie_httponly must be On',
];
$checks['session_cookie_samesite'] = [
'status' => ini_get('session.cookie_samesite') === 'Lax' ? 'pass' : 'fail',
'message' => 'session.cookie_samesite should be Lax or Strict',
];
return $checks;
}
}
package security
import (
"os"
"strings"
)
// ConfigCheck verifies production security settings.
type ConfigCheck struct {
Status string `json:"status"`
Message string `json:"message"`
}
func check(ok bool, pass, fail string) ConfigCheck {
if ok {
return ConfigCheck{"pass", pass}
}
return ConfigCheck{"fail", fail}
}
// envIs reports whether an environment variable equals want.
// An unset variable never counts as secure -- every check fails closed.
func envIs(key, want string) bool {
return strings.EqualFold(os.Getenv(key), want)
}
// CheckSecurityConfig validates that production settings are correct.
func CheckSecurityConfig() map[string]ConfigCheck {
sameSite := strings.ToLower(os.Getenv("SESSION_COOKIE_SAMESITE"))
return map[string]ConfigCheck{
"debug_mode": check(envIs("APP_DEBUG", "false"),
"Debug mode disabled",
"Debug mode must be disabled in production"),
"environment": check(envIs("APP_ENV", "production"),
"Running in production mode",
"APP_ENV should be production"),
"tls": check(envIs("TLS_ENABLED", "true"),
"TLS enabled",
"TLS must be enabled"),
// Session cookie hardening: without these, a valid session can be
// stolen over plain HTTP, read by JavaScript, or replayed cross-site.
"cookie_secure": check(envIs("SESSION_COOKIE_SECURE", "true"),
"Session cookie is HTTPS-only",
"SESSION_COOKIE_SECURE must be true"),
"cookie_httponly": check(envIs("SESSION_COOKIE_HTTPONLY", "true"),
"Session cookie is HttpOnly",
"SESSION_COOKIE_HTTPONLY must be true"),
"cookie_samesite": check(sameSite == "lax" || sameSite == "strict",
"SameSite is Lax or Strict",
"SESSION_COOKIE_SAMESITE should be Lax or Strict"),
}
}
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace App.Security;
public sealed record ConfigCheck(string Status, string Message);
// VULNERABLE: developer exception page and detailed errors left on in production.
// app.UseDeveloperExceptionPage(); // never unconditionally
// SECURE: production hardening checklist, verified at startup.
public sealed class SecurityConfigChecker(
IHostEnvironment environment,
IConfiguration configuration)
{
public IReadOnlyDictionary<string, ConfigCheck> Check()
{
var checks = new Dictionary<string, ConfigCheck>
{
["environment"] = environment.IsProduction()
? new ConfigCheck("pass", "Running in production mode")
: new ConfigCheck("fail", "ASPNETCORE_ENVIRONMENT should be Production"),
["detailed_errors"] = !configuration.GetValue<bool>("DetailedErrors")
? new ConfigCheck("pass", "Detailed errors disabled")
: new ConfigCheck("fail", "DetailedErrors must be disabled in production"),
["tls"] = configuration.GetValue<bool>("Https:Enabled")
? new ConfigCheck("pass", "TLS enabled")
: new ConfigCheck("fail", "TLS must be enabled"),
["cookie_secure"] = configuration.GetValue<CookieSecurePolicy>("Cookies:SecurePolicy")
== CookieSecurePolicy.Always
? new ConfigCheck("pass", "Cookies are HTTPS-only")
: new ConfigCheck("fail", "CookieSecurePolicy must be Always"),
["cookie_httponly"] = configuration.GetValue<bool>("Cookies:HttpOnly")
? new ConfigCheck("pass", "Cookies are HttpOnly")
: new ConfigCheck("fail", "Session cookies must be HttpOnly"),
["cookie_samesite"] = configuration.GetValue<SameSiteMode>("Cookies:SameSite")
is SameSiteMode.Lax or SameSiteMode.Strict
? new ConfigCheck("pass", "SameSite is Lax or Strict")
: new ConfigCheck("fail", "SameSite should be Lax or Strict"),
};
return checks;
}
}
import os
from dataclasses import dataclass
from typing import Literal
Status = Literal["pass", "fail"]
@dataclass(frozen=True, slots=True)
class ConfigCheck:
status: Status
message: str
def _check(condition: bool, ok: str, problem: str) -> ConfigCheck:
return ConfigCheck("pass", ok) if condition else ConfigCheck("fail", problem)
# VULNERABLE: debug mode left on in production leaks stack traces and settings.
# app = FastAPI(debug=True) # or DEBUG=True in Django settings
def check_security_config() -> dict[str, ConfigCheck]:
"""SECURE: production hardening checklist, verified at startup."""
return {
"debug_mode": _check(
os.getenv("DEBUG", "false").lower() == "false",
"Debug mode disabled",
"Debug mode must be disabled in production",
),
"environment": _check(
os.getenv("APP_ENV") == "production",
"Running in production mode",
"APP_ENV should be production",
),
"tls": _check(
os.getenv("TLS_ENABLED", "false").lower() == "true",
"TLS enabled",
"TLS must be enabled",
),
"cookie_secure": _check(
os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true",
"Session cookie is HTTPS-only",
"SESSION_COOKIE_SECURE must be true",
),
"cookie_httponly": _check(
os.getenv("SESSION_COOKIE_HTTPONLY", "false").lower() == "true",
"Session cookie is HttpOnly",
"SESSION_COOKIE_HTTPONLY must be true",
),
"cookie_samesite": _check(
os.getenv("SESSION_COOKIE_SAMESITE", "").lower() in {"lax", "strict"},
"SameSite is Lax or Strict",
"SESSION_COOKIE_SAMESITE should be Lax or Strict",
),
}
## A06: Vulnerable and Outdated Components
Использование компонентов с известными уязвимостями.
<?php
declare(strict_types=1);
// Check: composer audit (built-in since Composer 2.4)
// Check: Symfony Security Checker
/**
* Dependency audit report generator.
*/
final readonly class DependencyAuditor
{
/**
* Parse composer audit output and categorize by severity.
*
* @param array<array{advisoryId: string, packageName: string, severity: string}> $advisories
* @return array{critical: int, high: int, medium: int, low: int, packages: array<string>}
*/
public function categorize(array $advisories): array
{
$result = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'packages' => []];
foreach ($advisories as $advisory) {
$severity = match (strtolower($advisory['severity'])) {
'critical' => 'critical',
'high' => 'high',
'medium' => 'medium',
'low' => 'low',
// An unrecognised severity is escalated, never silently
// dropped into a new key nobody reads
default => 'critical',
};
++$result[$severity];
$result['packages'][] = $advisory['packageName'];
}
$result['packages'] = array_unique($result['packages']);
return $result;
}
}
package security
import "strings"
// Advisory represents a security advisory for a dependency.
type Advisory struct {
AdvisoryID string `json:"advisory_id"`
PackageName string `json:"package_name"`
Severity string `json:"severity"`
}
// AuditResult categorizes advisories by severity.
type AuditResult struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Packages []string `json:"packages"`
}
// CategorizeAdvisories groups advisories by severity level.
// In Go, use `govulncheck ./...` for dependency scanning.
func CategorizeAdvisories(advisories []Advisory) AuditResult {
result := AuditResult{}
seen := make(map[string]bool)
for _, a := range advisories {
switch strings.ToLower(a.Severity) {
case "critical":
result.Critical++
case "high":
result.High++
case "medium":
result.Medium++
case "low":
result.Low++
default:
// An unrecognised severity is escalated rather than dropped:
// a switch without a default hides advisories nobody counted.
result.Critical++
}
if !seen[a.PackageName] {
result.Packages = append(result.Packages, a.PackageName)
seen[a.PackageName] = true
}
}
return result
}
namespace App.Security;
// Scan with: dotnet list package --vulnerable --include-transitive
// Enforce in CI with: <NuGetAudit>true</NuGetAudit> and <NuGetAuditMode>all</NuGetAuditMode>
public sealed record Advisory(string AdvisoryId, string PackageName, string Severity);
public sealed record AuditResult(
int Critical,
int High,
int Medium,
int Low,
IReadOnlyList<string> Packages);
public static class DependencyAuditor
{
// Group advisories by severity level.
public static AuditResult Categorize(IEnumerable<Advisory> advisories)
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = 0,
["high"] = 0,
["medium"] = 0,
["low"] = 0,
};
var packages = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (Advisory advisory in advisories)
{
// Unknown severities are counted as critical rather than silently dropped.
string severity = counts.ContainsKey(advisory.Severity) ? advisory.Severity : "critical";
counts[severity]++;
if (seen.Add(advisory.PackageName))
{
packages.Add(advisory.PackageName);
}
}
return new AuditResult(
counts["critical"],
counts["high"],
counts["medium"],
counts["low"],
packages);
}
}
from collections import Counter
from dataclasses import dataclass
# Scan with: pip-audit (or: uv pip audit / safety check)
KNOWN_SEVERITIES = ("critical", "high", "medium", "low")
@dataclass(frozen=True, slots=True)
class Advisory:
advisory_id: str
package_name: str
severity: str
@dataclass(frozen=True, slots=True)
class AuditResult:
critical: int
high: int
medium: int
low: int
packages: list[str]
def categorize(advisories: list[Advisory]) -> AuditResult:
"""Group advisories by severity level."""
counts: Counter[str] = Counter()
packages: list[str] = []
seen: set[str] = set()
for advisory in advisories:
severity = advisory.severity.lower()
# Unknown severities are counted as critical rather than silently dropped.
counts[severity if severity in KNOWN_SEVERITIES else "critical"] += 1
if advisory.package_name not in seen:
seen.add(advisory.package_name)
packages.append(advisory.package_name)
return AuditResult(
critical=counts["critical"],
high=counts["high"],
medium=counts["medium"],
low=counts["low"],
packages=packages,
)
## A07: Identification and Authentication Failures
Проблемы аутентификации: слабые пароли, неправильное управление сессиями.
<?php
declare(strict_types=1);
// SECURE: Proper authentication with brute-force protection
final readonly class AuthService
{
public function __construct(
private UserRepository $users,
private RateLimiter $limiter,
private SessionManager $sessions,
) {}
public function login(string $email, string $password, string $ip): AuthResult
{
// Rate limiting by IP and email
$ipKey = "login_ip:{$ip}";
$emailKey = "login_email:{$email}";
if (!$this->limiter->attempt($ipKey, maxAttempts: 20, perSeconds: 900)
|| !$this->limiter->attempt($emailKey, maxAttempts: 5, perSeconds: 900)
) {
return AuthResult::rateLimited();
}
$user = $this->users->findByEmail($email);
$storedHash = $user?->getPasswordHash() ?? self::dummyHash();
// Always run the verifier, even for an unknown email. Skipping it
// would make missing accounts answer measurably faster, which is a
// free user-enumeration oracle
$passwordValid = password_verify($password, $storedHash);
if ($user === null || !$passwordValid) {
// Don't reveal whether email exists
return AuthResult::failed('Invalid credentials.');
}
if (!$user->isActive()) {
return AuthResult::failed('Account is deactivated.');
}
// Regenerate session ID to prevent fixation
$this->sessions->regenerate();
// Reset rate limiters on success
$this->limiter->reset($ipKey);
$this->limiter->reset($emailKey);
return AuthResult::success($user);
}
/**
* Hash of a fixed dummy password, computed once per process and verified
* against when the account does not exist, to equalize response time.
*/
private static function dummyHash(): string
{
static $hash = null;
return $hash ??= password_hash(
'dummy-password-for-timing-equalization',
PASSWORD_ARGON2ID,
);
}
}
package auth
import (
"context"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// dummyHash is verified against when the account does not exist, so that a
// missing user costs the same time as a wrong password. Without it, unknown
// emails answer measurably faster and become a user-enumeration oracle.
var dummyHash = mustHash("dummy-password-for-timing-equalization")
func mustHash(password string) []byte {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(fmt.Sprintf("hash dummy password: %v", err))
}
return hash
}
// AuthService handles authentication with brute-force protection.
type AuthService struct {
users UserRepository
limiter RateLimiter
sessions SessionManager
}
// Login authenticates a user with rate limiting.
func (s *AuthService) Login(ctx context.Context, email, password, ip string) (*User, error) {
ipKey := fmt.Sprintf("login_ip:%s", ip)
emailKey := fmt.Sprintf("login_email:%s", email)
if !s.limiter.Attempt(ipKey, 20, 900) || !s.limiter.Attempt(emailKey, 5, 900) {
return nil, ErrRateLimited
}
user, err := s.users.FindByEmail(ctx, email)
if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, fmt.Errorf("look up user: %w", err)
}
storedHash := dummyHash
if user != nil {
storedHash = []byte(user.PasswordHash)
}
// Always run the comparison, even for an unknown email.
passwordErr := bcrypt.CompareHashAndPassword(storedHash, []byte(password))
if user == nil || passwordErr != nil {
// Don't reveal whether email exists
return nil, ErrInvalidCredentials
}
if !user.Active {
return nil, ErrAccountDeactivated
}
// Issue a fresh session identifier to prevent session fixation: reusing
// the pre-login identifier lets an attacker who planted it ride along.
if err := s.sessions.Regenerate(ctx); err != nil {
return nil, fmt.Errorf("regenerate session: %w", err)
}
// Reset rate limiters on success
s.limiter.Reset(ipKey)
s.limiter.Reset(emailKey)
return user, nil
}
using Microsoft.AspNetCore.Identity;
namespace App.Auth;
// SECURE: authentication with brute-force protection and session fixation defence.
public sealed class AuthService(
IUserRepository users,
IPasswordHasher<User> passwordHasher,
IRateLimiter limiter)
{
private static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
public async Task<AuthResult> LoginAsync(
string email,
string password,
string ip,
HttpContext httpContext,
CancellationToken ct = default)
{
string ipKey = $"login_ip:{ip}";
string emailKey = $"login_email:{email}";
if (!await limiter.AttemptAsync(ipKey, 20, Window, ct)
|| !await limiter.AttemptAsync(emailKey, 5, Window, ct))
{
return AuthResult.RateLimited();
}
User? user = await users.FindByEmailAsync(email, ct);
// Verify against a dummy hash when the user is missing so that the
// response time does not reveal whether the email exists.
PasswordVerificationResult verification = user is null
? PasswordVerificationResult.Failed
: passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (user is null || verification == PasswordVerificationResult.Failed)
{
return AuthResult.Failed("Invalid credentials.");
}
if (!user.IsActive)
{
return AuthResult.Failed("Account is deactivated.");
}
// Issue a fresh session identifier to prevent session fixation.
await httpContext.SignOutAsync();
await httpContext.SignInAsync(user.ToClaimsPrincipal());
await limiter.ResetAsync(ipKey, ct);
await limiter.ResetAsync(emailKey, ct);
return AuthResult.Success(user);
}
}
from datetime import timedelta
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
WINDOW = timedelta(minutes=15)
_hasher = PasswordHasher(memory_cost=65536, time_cost=4, parallelism=3)
# Verified against this dummy hash when the user is missing, so response time
# does not reveal whether the email exists.
_DUMMY_HASH = _hasher.hash("dummy-password-for-timing-equalization")
class AuthService:
"""SECURE: authentication with brute-force protection."""
def __init__(self, users: UserRepository, limiter: RateLimiter, sessions: SessionManager) -> None:
self._users = users
self._limiter = limiter
self._sessions = sessions
async def login(self, email: str, password: str, ip: str) -> AuthResult:
ip_key = f"login_ip:{ip}"
email_key = f"login_email:{email}"
ip_ok = await self._limiter.attempt(ip_key, max_attempts=20, window=WINDOW)
email_ok = await self._limiter.attempt(email_key, max_attempts=5, window=WINDOW)
if not ip_ok or not email_ok:
return AuthResult.rate_limited()
user = await self._users.find_by_email(email)
stored_hash = user.password_hash if user is not None else _DUMMY_HASH
try:
_hasher.verify(stored_hash, password)
except (VerifyMismatchError, VerificationError):
# Do not reveal whether the email exists.
return AuthResult.failed("Invalid credentials.")
if user is None:
return AuthResult.failed("Invalid credentials.")
if not user.is_active:
return AuthResult.failed("Account is deactivated.")
# Issue a fresh session identifier to prevent session fixation.
await self._sessions.regenerate()
await self._limiter.reset(ip_key)
await self._limiter.reset(email_key)
return AuthResult.success(user)
## A08: Software and Data Integrity Failures
Нарушение целостности данных — непроверенные обновления, десериализация.
<?php
declare(strict_types=1);
// VULNERABLE: Unsafe deserialization
$data = unserialize($_POST['data']); // Remote Code Execution risk!
// SECURE: Use JSON instead of serialize
final readonly class SafeDeserializer
{
/**
* Safely decode user-provided JSON data.
*
* @return array<string, mixed>
* @throws \JsonException
*/
public function decode(string $json): array
{
// JSON is safe — no object instantiation
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \InvalidArgumentException('Expected JSON object');
}
return $data;
}
/**
* Verify webhook signature to ensure data integrity.
*/
public function verifyWebhookSignature(
string $payload,
string $signature,
string $secret,
): bool {
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
}
package security
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
// VULNERABLE: Go does not have PHP's unserialize, but
// encoding/gob can be dangerous with untrusted input.
// Always use encoding/json for user-provided data.
// SafeDecode safely decodes user-provided JSON data.
func SafeDecode(data []byte) (map[string]any, error) {
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return result, nil
}
// VerifyWebhookSignature ensures data integrity via HMAC.
func VerifyWebhookSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace App.Security;
public static class SafeDeserializer
{
// VULNERABLE: BinaryFormatter deserializes arbitrary types — Remote Code
// Execution risk. It is removed in .NET 9; never reintroduce it.
// object data = new BinaryFormatter().Deserialize(stream);
// SECURE: JSON with a fixed target type — no arbitrary type instantiation.
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
// Never enable polymorphic type resolution for untrusted input.
};
public static T Decode<T>(string json)
=> JsonSerializer.Deserialize<T>(json, Options)
?? throw new JsonException("Expected a JSON object");
// Verify a webhook signature to ensure data integrity.
public static bool VerifyWebhookSignature(
ReadOnlySpan<byte> payload,
string signature,
string secret)
{
Span<byte> expected = stackalloc byte[HMACSHA256.HashSizeInBytes];
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload, expected);
// FromHexString throws on malformed input, so validate length first.
if (signature.Length != HMACSHA256.HashSizeInBytes * 2)
{
return false;
}
byte[] provided;
try
{
provided = Convert.FromHexString(signature);
}
catch (FormatException)
{
return false;
}
// Constant-time comparison prevents signature-guessing via timing.
return CryptographicOperations.FixedTimeEquals(expected, provided);
}
}
import hashlib
import hmac
import json
from typing import Any
# VULNERABLE: pickle executes arbitrary code during load — never use it
# on user-provided data.
# data = pickle.loads(request.body) # Remote Code Execution risk!
def decode(raw: str) -> dict[str, Any]:
"""SECURE: JSON never instantiates arbitrary objects."""
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("expected a JSON object")
return data
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify a webhook signature to ensure data integrity."""
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
# compare_digest is constant-time, preventing signature guessing via timing.
return hmac.compare_digest(expected, signature)
## A09: Security Logging and Monitoring Failures
Недостаточное логирование событий безопасности.
<?php
declare(strict_types=1);
namespace App\Security;
use Psr\Log\LoggerInterface;
final readonly class SecurityAuditLogger
{
public function __construct(
private LoggerInterface $logger,
) {}
public function logLoginAttempt(string $email, bool $success, string $ip): void
{
$this->logger->info('Authentication attempt', [
'event' => 'auth.login',
'email' => $email,
'success' => $success,
'ip' => $ip,
'timestamp' => (new \DateTimeImmutable())->format(\DATE_ATOM),
]);
}
public function logAccessDenied(string $userId, string $resource, string $action): void
{
$this->logger->warning('Access denied', [
'event' => 'auth.access_denied',
'user_id' => $userId,
'resource' => $resource,
'action' => $action,
]);
}
/**
* @param array<string, mixed> $context Must not carry secrets or raw PII —
* audit logs outlive application logs
*/
public function logSuspiciousActivity(string $description, array $context = []): void
{
$this->logger->error('Suspicious activity detected', [
'event' => 'security.suspicious',
'description' => $description,
...$context,
]);
}
public function logDataAccess(string $userId, string $dataType, string $action): void
{
$this->logger->info('Sensitive data access', [
'event' => 'audit.data_access',
'user_id' => $userId,
'data_type' => $dataType,
'action' => $action,
]);
}
}
package security
import (
"context"
"log/slog"
"time"
)
// AuditLogger logs security-relevant events.
type AuditLogger struct {
logger *slog.Logger
}
// NewAuditLogger creates an audit logger.
func NewAuditLogger(logger *slog.Logger) *AuditLogger {
return &AuditLogger{logger: logger}
}
// LogLoginAttempt records an authentication attempt.
func (l *AuditLogger) LogLoginAttempt(email string, success bool, ip string) {
l.logger.Info("Authentication attempt",
slog.String("event", "auth.login"),
slog.String("email", email),
slog.Bool("success", success),
slog.String("ip", ip),
slog.String("timestamp", time.Now().Format(time.RFC3339)),
)
}
// LogAccessDenied records an authorization failure.
func (l *AuditLogger) LogAccessDenied(userID, resource, action string) {
l.logger.Warn("Access denied",
slog.String("event", "auth.access_denied"),
slog.String("user_id", userID),
slog.String("resource", resource),
slog.String("action", action),
)
}
// LogSuspiciousActivity records suspicious security events.
// Callers must keep secrets and raw PII out of attrs -- audit logs are
// retained far longer, and read by more people, than application logs.
func (l *AuditLogger) LogSuspiciousActivity(ctx context.Context, description string, attrs ...slog.Attr) {
allAttrs := append([]slog.Attr{
slog.String("event", "security.suspicious"),
slog.String("description", description),
}, attrs...)
l.logger.LogAttrs(ctx, slog.LevelError, "Suspicious activity detected", allAttrs...)
}
// LogDataAccess records access to sensitive data.
func (l *AuditLogger) LogDataAccess(userID, dataType, action string) {
l.logger.Info("Sensitive data access",
slog.String("event", "audit.data_access"),
slog.String("user_id", userID),
slog.String("data_type", dataType),
slog.String("action", action),
)
}
using Microsoft.Extensions.Logging;
namespace App.Security;
public sealed class SecurityAuditLogger(ILogger<SecurityAuditLogger> logger)
{
// Structured logging: named placeholders become queryable fields,
// not interpolated strings.
public void LogLoginAttempt(string email, bool success, string ip)
=> logger.LogInformation(
"Authentication attempt {Event} {Email} {Success} {Ip} {Timestamp}",
"auth.login",
email,
success,
ip,
DateTimeOffset.UtcNow.ToString("O"));
public void LogAccessDenied(string userId, string resource, string action)
=> logger.LogWarning(
"Access denied {Event} {UserId} {Resource} {Action}",
"auth.access_denied",
userId,
resource,
action);
public void LogSuspiciousActivity(
string description,
IReadOnlyDictionary<string, object?>? context = null)
{
using IDisposable? scope = context is null
? null
: logger.BeginScope(context);
logger.LogError(
"Suspicious activity detected {Event} {Description}",
"security.suspicious",
description);
}
public void LogDataAccess(string userId, string dataType, string action)
=> logger.LogInformation(
"Sensitive data access {Event} {UserId} {DataType} {Action}",
"audit.data_access",
userId,
dataType,
action);
}
Сервер выполняет HTTP-запрос на URL, контролируемый злоумышленником.
<?php
declare(strict_types=1);
// VULNERABLE: Fetch arbitrary URL
function fetchUrlVulnerable(string $url): string
{
// Attacker can access internal services: http://169.254.169.254/metadata
return file_get_contents($url);
}
// SECURE: Validate and restrict URLs
final readonly class SafeHttpClient
{
private const ALLOWED_SCHEMES = ['https'];
/** @var array<string> */
private const BLOCKED_IP_RANGES = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'169.254.0.0/16', // AWS metadata
'0.0.0.0/8',
];
/**
* Fetch URL with SSRF protection.
*/
public function fetch(string $url): string
{
$parsed = parse_url($url);
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
throw new \InvalidArgumentException('Invalid URL');
}
// Check scheme
if (!in_array(strtolower($parsed['scheme']), self::ALLOWED_SCHEMES, true)) {
throw new \InvalidArgumentException('Only HTTPS URLs are allowed');
}
// Resolve the hostname and check EVERY address. gethostbyname returns
// only the first record, so a host that also resolves to an internal
// IP would slip straight through
$ips = gethostbynamel($parsed['host']);
if ($ips === false || $ips === []) {
throw new \InvalidArgumentException('Could not resolve host');
}
foreach ($ips as $ip) {
if ($this->isBlockedIp($ip)) {
throw new \InvalidArgumentException('Access to internal networks is forbidden');
}
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false, // Don't follow redirects (SSRF bypass)
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$result = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($result === false) {
throw new \RuntimeException('Request failed');
}
if ($statusCode !== 200) {
throw new \RuntimeException("HTTP {$statusCode}");
}
return $result;
}
private function isBlockedIp(string $ip): bool
{
// Covers IPv6 as well: ::1 and fe80::/10 are internal too, and the
// IPv4 CIDR list below can never match them
$public = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
);
if ($public === false) {
return true;
}
$ipLong = ip2long($ip);
if ($ipLong === false) {
return false; // Non-IPv4 address that already passed the filter
}
foreach (self::BLOCKED_IP_RANGES as $range) {
[$subnet, $mask] = explode('/', $range);
$subnetLong = ip2long($subnet);
$maskLong = ~((1 << (32 - (int) $mask)) - 1);
if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
return true;
}
}
return false;
}
}
package security
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// blockedCIDRs contains IP ranges that must not be accessed.
var blockedCIDRs = []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8",
}
// SafeHTTPClient fetches URLs with SSRF protection.
type SafeHTTPClient struct {
client *http.Client
}
// NewSafeHTTPClient creates an HTTP client that blocks internal networks.
func NewSafeHTTPClient() *SafeHTTPClient {
return &SafeHTTPClient{
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Don't follow redirects (SSRF bypass)
},
},
}
}
// Fetch retrieves a URL after validating it is not an internal address.
func (c *SafeHTTPClient) Fetch(ctx context.Context, rawURL string) (string, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
if strings.ToLower(parsed.Scheme) != "https" {
return "", fmt.Errorf("only HTTPS URLs are allowed")
}
// Resolve hostname and check against blocked ranges
ips, err := net.LookupIP(parsed.Hostname())
if err != nil {
return "", fmt.Errorf("DNS resolution failed: %w", err)
}
for _, ip := range ips {
if isBlockedIP(ip) {
return "", fmt.Errorf("access to internal networks is forbidden")
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
return string(body), err
}
func isBlockedIP(ip net.IP) bool {
// Covers IPv6 as well: ::1 and fe80::/10 are internal too, and the IPv4
// CIDR list below can never match them.
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
for _, cidr := range blockedCIDRs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
// A malformed entry must fail closed, not silently allow the IP.
return true
}
if network.Contains(ip) {
return true
}
}
return false
}
using System.Net;
using System.Net.Sockets;
namespace App.Security;
// VULNERABLE: fetching an arbitrary caller-supplied URL.
// string body = await httpClient.GetStringAsync(url);
// Attacker can reach internal services: http://169.254.169.254/metadata
// SECURE: validate the scheme and the resolved IPs before any request.
public sealed class SafeHttpClient
{
private static readonly (IPAddress Network, int Prefix)[] BlockedRanges =
[
(IPAddress.Parse("10.0.0.0"), 8),
(IPAddress.Parse("172.16.0.0"), 12),
(IPAddress.Parse("192.168.0.0"), 16),
(IPAddress.Parse("127.0.0.0"), 8),
(IPAddress.Parse("169.254.0.0"), 16), // Cloud metadata endpoint
(IPAddress.Parse("0.0.0.0"), 8),
];
private readonly HttpClient _client = new(new SocketsHttpHandler
{
// Do not follow redirects — a 302 to an internal host bypasses the check.
AllowAutoRedirect = false,
})
{
Timeout = TimeSpan.FromSeconds(10),
};
public async Task<string> FetchAsync(string rawUrl, CancellationToken ct = default)
{
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out Uri? uri))
{
throw new ArgumentException("Invalid URL", nameof(rawUrl));
}
if (uri.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Only HTTPS URLs are allowed", nameof(rawUrl));
}
IPAddress[] addresses = await Dns.GetHostAddressesAsync(uri.Host, ct);
if (addresses.Length == 0 || addresses.Any(IsBlocked))
{
throw new ArgumentException("Access to internal networks is forbidden", nameof(rawUrl));
}
using HttpResponseMessage response = await _client.GetAsync(uri, ct);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
return await response.Content.ReadAsStringAsync(ct);
}
private static bool IsBlocked(IPAddress ip)
{
// IPv6 loopback and link-local have their own representations.
if (IPAddress.IsLoopback(ip) || ip.IsIPv6LinkLocal)
{
return true;
}
if (ip.AddressFamily != AddressFamily.InterNetwork)
{
// Only IPv4 ranges are enumerated below; block anything else by default.
return !ip.IsIPv4MappedToIPv6 || IsBlocked(ip.MapToIPv4());
}
return BlockedRanges.Any(range => ip.IsInSubnet(range.Network, range.Prefix));
}
}
file static class IPAddressExtensions
{
public static bool IsInSubnet(this IPAddress address, IPAddress network, int prefixLength)
{
uint addressBits = ToUInt32(address);
uint networkBits = ToUInt32(network);
uint mask = prefixLength == 0 ? 0u : uint.MaxValue << (32 - prefixLength);
return (addressBits & mask) == (networkBits & mask);
}
private static uint ToUInt32(IPAddress address)
=> BitConverter.ToUInt32(address.GetAddressBytes().Reverse().ToArray());
}
import ipaddress
import socket
from urllib.parse import urlsplit
import httpx
REQUEST_TIMEOUT_SECONDS = 10
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # Cloud metadata endpoint
ipaddress.ip_network("0.0.0.0/8"),
]
# VULNERABLE: fetching an arbitrary caller-supplied URL.
# response = httpx.get(url)
# Attacker can reach internal services: http://169.254.169.254/metadata
def _is_blocked(ip: str) -> bool:
address = ipaddress.ip_address(ip)
if address.is_loopback or address.is_link_local or address.is_private:
return True
return any(address in network for network in BLOCKED_NETWORKS)
async def fetch(raw_url: str) -> str:
"""SECURE: validate the scheme and every resolved IP before requesting."""
parts = urlsplit(raw_url)
if parts.scheme != "https":
raise ValueError("only HTTPS URLs are allowed")
if not parts.hostname:
raise ValueError("invalid URL")
try:
resolved = socket.getaddrinfo(parts.hostname, 443, proto=socket.IPPROTO_TCP)
except socket.gaierror as exc:
raise ValueError("DNS resolution failed") from exc
for family, _type, _proto, _canonname, sockaddr in resolved:
if _is_blocked(sockaddr[0]):
raise ValueError("access to internal networks is forbidden")
async with httpx.AsyncClient(
timeout=REQUEST_TIMEOUT_SECONDS,
# Do not follow redirects — a 302 to an internal host bypasses the check.
follow_redirects=False,
) as client:
response = await client.get(raw_url)
if response.status_code != httpx.codes.OK:
raise RuntimeError(f"HTTP {response.status_code}")
return response.text