Mid💻Практика14 min

OWASP Top 10

Все 10 категорий уязвимостей OWASP с PHP-примерами уязвимого и защищённого кода

OWASP Top 10

Что такое OWASP Top 10

OWASP Top 10 — список десяти наиболее критичных категорий уязвимостей веб-приложений. Обновляется каждые 3-4 года на основе данных из реальных приложений.

A01: Broken Access Control

Нарушение контроля доступа — пользователь может выполнять действия за пределами своих прав.

PHPGo
<?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);
        $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"
)

// 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
	}

	currentUser := UserFromContext(r.Context())
	if order.UserID != currentUser.ID && !currentUser.HasRole("admin") {
		return nil, errors.New("you cannot view this order")
	}

	return order, nil
}
## A02: Cryptographic Failures

Неправильное использование криптографии или её отсутствие.

PHPGo
<?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 "golang.org/x/crypto/argon2"

// VULNERABLE: weak hashing
// hash := md5.Sum([]byte(password)) // MD5 is broken!

// SECURE: strong hashing with Argon2id
func HashPassword(password string, salt []byte) []byte {
	return argon2.IDKey([]byte(password), salt, 4, 65536, 3, 32)
}
## A03: Injection

Внедрение вредоносного кода через пользовательский ввод.

SQL Injection

PHPGo
<?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()
}
### Command Injection
PHPGo
<?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: Escape arguments, validate input
function resizeImageSecure(string $filename, int $width): void
{
    // Validate filename contains only safe characters
    if (!preg_match('/^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$/i', $filename)) {
        throw new \InvalidArgumentException('Invalid filename');
    }

    $escapedFilename = escapeshellarg($filename);
    $escapedWidth = escapeshellarg((string) $width);

    exec("convert {$escapedFilename} -resize {$escapedWidth}x output.jpg");
}
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()
}
## A04: Insecure Design

Недостатки в архитектуре, которые нельзя исправить одной реализацией.

PHPGo
<?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)

	s.store.Save(email, token, 30*60) // 30 min TTL
	return s.mailer.SendResetLink(email, token)
}
## A05: Security Misconfiguration

Неправильные настройки безопасности серверов, фреймворков, приложений.

PHPGo
<?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
        $checks['debug_mode'] = [
            'status' => $_ENV['APP_DEBUG'] === '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"

// ConfigCheck verifies production security settings.
type ConfigCheck struct {
	Status  string `json:"status"`
	Message string `json:"message"`
}

// CheckSecurityConfig validates that production settings are correct.
func CheckSecurityConfig() map[string]ConfigCheck {
	checks := map[string]ConfigCheck{}

	// Debug mode
	if os.Getenv("APP_DEBUG") == "false" {
		checks["debug_mode"] = ConfigCheck{"pass", "Debug mode disabled"}
	} else {
		checks["debug_mode"] = ConfigCheck{"fail", "Debug mode must be disabled in production"}
	}

	// Environment
	if os.Getenv("APP_ENV") == "production" {
		checks["environment"] = ConfigCheck{"pass", "Running in production mode"}
	} else {
		checks["environment"] = ConfigCheck{"fail", "APP_ENV should be production"}
	}

	// TLS
	if os.Getenv("TLS_ENABLED") == "true" {
		checks["tls"] = ConfigCheck{"pass", "TLS enabled"}
	} else {
		checks["tls"] = ConfigCheck{"fail", "TLS must be enabled"}
	}

	return checks
}
## A06: Vulnerable and Outdated Components

Использование компонентов с известными уязвимостями.

PHPGo
<?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 = strtolower($advisory['severity']);
            $result[$severity] = ($result[$severity] ?? 0) + 1;
            $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++
		}
		if !seen[a.PackageName] {
			result.Packages = append(result.Packages, a.PackageName)
			seen[a.PackageName] = true
		}
	}
	return result
}
## A07: Identification and Authentication Failures

Проблемы аутентификации: слабые пароли, неправильное управление сессиями.

PHPGo
<?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);

        if ($user === null || !password_verify($password, $user->getPasswordHash())) {
            // 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);
    }
}
package auth

import (
	"context"
	"fmt"

	"golang.org/x/crypto/bcrypt"
)

// AuthService handles authentication with brute-force protection.
type AuthService struct {
	users   UserRepository
	limiter RateLimiter
}

// 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 {
		// Don't reveal whether email exists
		return nil, ErrInvalidCredentials
	}

	if err := bcrypt.CompareHashAndPassword(
		[]byte(user.PasswordHash), []byte(password),
	); err != nil {
		return nil, ErrInvalidCredentials
	}

	if !user.Active {
		return nil, ErrAccountDeactivated
	}

	// Reset rate limiters on success
	s.limiter.Reset(ipKey)
	s.limiter.Reset(emailKey)

	return user, nil
}
## A08: Software and Data Integrity Failures

Нарушение целостности данных — непроверенные обновления, десериализация.

PHPGo
<?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))
}
## A09: Security Logging and Monitoring Failures

Недостаточное логирование событий безопасности.

PHPGo
<?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,
        ]);
    }

    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 (
	"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.
func (l *AuditLogger) LogSuspiciousActivity(description string, attrs ...slog.Attr) {
	allAttrs := append([]slog.Attr{
		slog.String("event", "security.suspicious"),
		slog.String("description", description),
	}, attrs...)
	l.logger.LogAttrs(nil, slog.LevelError, "Suspicious activity detected", allAttrs...)
}
## A10: Server-Side Request Forgery (SSRF)

Сервер выполняет HTTP-запрос на URL, контролируемый злоумышленником.

PHPGo
<?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 hostname to IP and check against blocked ranges
        $ip = gethostbyname($parsed['host']);

        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 ($statusCode !== 200) {
            throw new \RuntimeException("HTTP {$statusCode}");
        }

        return $result;
    }

    private function isBlockedIp(string $ip): bool
    {
        $ipLong = ip2long($ip);

        if ($ipLong === false) {
            return true; // Block unresolvable
        }

        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 {
	for _, cidr := range blockedCIDRs {
		_, network, _ := net.ParseCIDR(cidr)
		if network.Contains(ip) {
			return true
		}
	}
	return false
}
## Итоги
# Категория Ключевая защита
A01 Broken Access Control Проверка прав на каждый ресурс
A02 Cryptographic Failures Argon2id, AES-256, TLS 1.3
A03 Injection Parameterized queries, escaping
A04 Insecure Design Threat modeling, rate limiting
A05 Security Misconfiguration Hardening checklist, no debug in prod
A06 Vulnerable Components composer audit, автоматические обновления
A07 Auth Failures Brute-force protection, session management
A08 Integrity Failures JSON вместо unserialize, HMAC
A09 Logging Failures Audit log для всех security-событий
A10 SSRF Whitelist URLs, block internal IPs