OWASP Top 10
Что такое OWASP Top 10
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);
$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
}
Неправильное использование криптографии или её отсутствие.
<?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)
}
Внедрение вредоносного кода через пользовательский ввод.
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()
}
<?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()
}
Недостатки в архитектуре, которые нельзя исправить одной реализацией.
<?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)
}
Неправильные настройки безопасности серверов, фреймворков, приложений.
<?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
}
Использование компонентов с известными уязвимостями.
<?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
}
Проблемы аутентификации: слабые пароли, неправильное управление сессиями.
<?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
}
Нарушение целостности данных — непроверенные обновления, десериализация.
<?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))
}
Недостаточное логирование событий безопасности.
<?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...)
}
Сервер выполняет 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 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 |