← назад к разделу

В маркетплейсе файлы ходят в обе стороны. Покупатель скачивает счёт по заказу, продавец загружает фото товара, покупатель прикладывает к обращению в поддержку снимок разбитой посылки. Каждый такой обмен открывает две двери: клиент может указать, какой файл прочитать или записать, и может решить, что окажется внутри файла. Первая дверь называется обходом пути, вторая опасной загрузкой.

Обязательно

Как устроен обход пути и чем грозитспросят на собеседовании

Обход пути (path traversal) - это CWE-22 «Improper Limitation of a Pathname to a Restricted Directory»: сервер строит путь к файлу из ввода клиента и не следит, чтобы итог остался внутри нужного каталога. Шире него CWE-73 «External Control of File Name or Path»: клиент вообще управляет именем или путём файла, с которым работает сервер.

Сценарий на маркетплейсе. Счета лежат в /srv/shop/invoices/, а ссылка «Скачать счёт» выглядит так: GET /invoices?file=order-1001.pdf. Покупатель меняет параметр на ../../../etc/passwd или ../secrets/db.env, и сервер честно склеивает /srv/shop/invoices/../secrets/db.env. Операционная система поднимается на уровень выше и отдаёт пароль от базы.

Варианты одной атаки, которые перечисляет OWASP Path Traversal:

  • кодирование: %2e%2e%2f и ..%2f означают ../, а двойное %252e%252e%255c раскодируется в ..\ только на втором проходе, так что проверка после первого декодирования его не увидит;
  • абсолютный путь: /etc/passwd вместо относительного, и многие функции склейки путей просто отбрасывают базовый каталог;
  • разделители Windows: ..\..\ там, где проверка ищет только /;
  • символические ссылки: путь лексически внутри каталога, но одна из папок на диске ведёт наружу;
  • нулевой байт исторически: secret.doc%00.pdf, где проверка видела .pdf, а система открывала .doc. Современные Java, Go, Node и Python строку с нулевым байтом в файловый вызов не пропускают (это видно в примере ниже), но в старом коде и в обёртках над C это всё ещё встречается.

Последствия зависят от операции. Чтение отдаёт конфиги, ключи, чужие счета и исходники. Запись хуже: имя ../../app/config/application.yml в загрузке перезаписывает настройки, а файл в каталоге, откуда сервер исполняет код, превращается в удалённое выполнение.

Как выглядит в коде

Две версии чтения счёта. Первая склеивает строки, вторая пытается проверять, но сравнивает строки по префиксу. Программа создаёт во временном каталоге invoices/, рядом secrets/ и каталог с похожим именем invoices-internal/ с выплатами продавцам, и обе версии отдают то, что не должны.

живой пример

import java.nio.file.Files;
import java.nio.file.Path;

public class InvoiceTraversal {
    static String readNaive(Path base, String name) throws Exception {
        return Files.readString(Path.of(base + "/" + name));
    }

    static String readWithStringPrefix(Path base, String name) throws Exception {
        Path full = base.resolve(name).normalize();
        if (!full.toString().startsWith(base.toString())) {
            throw new SecurityException("вне каталога");
        }
        return Files.readString(full);
    }

    public static void main(String[] args) throws Exception {
        Path shop = Files.createTempDirectory("shop");
        Path invoices = Files.createDirectories(shop.resolve("invoices"));
        Files.writeString(invoices.resolve("order-1001.txt"), "Счёт 1001: 4990 руб.");
        Files.writeString(Files.createDirectories(shop.resolve("secrets")).resolve("db.env"), "DB_PASSWORD=s3cr3t");
        Files.writeString(Files.createDirectories(shop.resolve("invoices-internal")).resolve("payouts.csv"), "seller-7;120000");

        System.out.println("склейка:  order-1001.txt -> " + readNaive(invoices, "order-1001.txt"));
        System.out.println("склейка:  ../secrets/db.env -> " + readNaive(invoices, "../secrets/db.env"));
        System.out.println("префикс:  ../invoices-internal/payouts.csv -> "
                + readWithStringPrefix(invoices, "../invoices-internal/payouts.csv"));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

func readNaive(base, name string) string {
	data, err := os.ReadFile(base + "/" + name)
	if err != nil {
		return "ошибка: " + err.Error()
	}
	return string(data)
}

func readWithStringPrefix(base, name string) string {
	full := filepath.Join(base, name)
	if !strings.HasPrefix(full, base) {
		return "отказ: вне каталога"
	}
	data, err := os.ReadFile(full)
	if err != nil {
		return "ошибка: " + err.Error()
	}
	return string(data)
}

func main() {
	shop, _ := os.MkdirTemp("", "shop")
	defer os.RemoveAll(shop)
	write := func(rel, text string) {
		p := filepath.Join(shop, rel)
		os.MkdirAll(filepath.Dir(p), 0o755)
		os.WriteFile(p, []byte(text), 0o644)
	}
	write("invoices/order-1001.txt", "Счёт 1001: 4990 руб.")
	write("secrets/db.env", "DB_PASSWORD=s3cr3t")
	write("invoices-internal/payouts.csv", "seller-7;120000")
	invoices := filepath.Join(shop, "invoices")

	fmt.Println("склейка:  order-1001.txt ->", readNaive(invoices, "order-1001.txt"))
	fmt.Println("склейка:  ../secrets/db.env ->", readNaive(invoices, "../secrets/db.env"))
	fmt.Println("префикс:  ../invoices-internal/payouts.csv ->", readWithStringPrefix(invoices, "../invoices-internal/payouts.csv"))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const fs = require('fs');
const os = require('os');
const path = require('path');

function readNaive(base, name) {
  return fs.readFileSync(base + '/' + name, 'utf8');
}

function readWithStringPrefix(base, name) {
  const full = path.resolve(base, name);
  if (!full.startsWith(base)) throw new Error('вне каталога');
  return fs.readFileSync(full, 'utf8');
}

const shop = fs.mkdtempSync(path.join(os.tmpdir(), 'shop-'));
const write = (rel, text) => {
  fs.mkdirSync(path.dirname(path.join(shop, rel)), { recursive: true });
  fs.writeFileSync(path.join(shop, rel), text);
};
write('invoices/order-1001.txt', 'Счёт 1001: 4990 руб.');
write('secrets/db.env', 'DB_PASSWORD=s3cr3t');
write('invoices-internal/payouts.csv', 'seller-7;120000');
const invoices = path.join(shop, 'invoices');

console.log('склейка:  order-1001.txt ->', readNaive(invoices, 'order-1001.txt'));
console.log('склейка:  ../secrets/db.env ->', readNaive(invoices, '../secrets/db.env'));
console.log('префикс:  ../invoices-internal/payouts.csv ->',
  readWithStringPrefix(invoices, '../invoices-internal/payouts.csv'));
fs.rmSync(shop, { recursive: true });
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import os
import tempfile
from pathlib import Path


def read_naive(base: str, name: str) -> str:
    return open(base + "/" + name, encoding="utf-8").read()


def read_with_string_prefix(base: str, name: str) -> str:
    full = os.path.normpath(os.path.join(base, name))
    if not full.startswith(base):
        raise PermissionError("вне каталога")
    return open(full, encoding="utf-8").read()


with tempfile.TemporaryDirectory() as shop:
    for rel, text in [("invoices/order-1001.txt", "Счёт 1001: 4990 руб."),
                      ("secrets/db.env", "DB_PASSWORD=s3cr3t"),
                      ("invoices-internal/payouts.csv", "seller-7;120000")]:
        (Path(shop) / rel).parent.mkdir(parents=True, exist_ok=True)
        (Path(shop) / rel).write_text(text, encoding="utf-8")
    invoices = os.path.join(shop, "invoices")

    print("склейка:  order-1001.txt ->", read_naive(invoices, "order-1001.txt"))
    print("склейка:  ../secrets/db.env ->", read_naive(invoices, "../secrets/db.env"))
    print("префикс:  ../invoices-internal/payouts.csv ->",
          read_with_string_prefix(invoices, "../invoices-internal/payouts.csv"))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Вторая версия выглядит защищённой: путь нормализован, .. схлопнуты. Но /tmp/shop/invoices-internal/payouts.csv как строка начинается с /tmp/shop/invoices, и проверка пропускает соседний каталог. Строковый префикс не знает о границах сегментов пути. Документация Go по этой причине пометила filepath.HasPrefix устаревшей: «HasPrefix does not respect path boundaries and does not ignore case when required».

Как чинить обход путиспросят на собеседовании

Лучший путь: не строить путь из ввода вообще. Клиент присылает номер заказа, сервер находит в базе запись счёта, проверяет, что заказ принадлежит этому покупателю, и берёт из записи ключ файла, который сам же когда-то сгенерировал. Строка клиента до файловой системы не доходит, и обходить нечего. Этот же приём OWASP советует для отдачи загруженных файлов: обработчик сопоставляет идентификатор с файлом (someid -> file.ext). Проверку владения при этом никто не отменял: подставить чужой номер заказа проще, чем ../, и это уже IDOR из статьи про владение ресурсом.

Если путь от клиента нужен (вложенные папки в хранилище документов продавца), то правило из CWE-22: сначала декодировать и привести к каноническому виду, потом проверять. Каноническая форма убирает .. и, если функция ходит в файловую систему, символические ссылки. Проверка сравнивает пути по сегментам, а не строки. Ниже функция resolveInside(base, userPath) и семь запросов: обычный файл, вложенный, выход через .., абсолютный путь, соседний каталог с похожим именем, ссылка наружу и нулевой байт.

normalize() чисто лексический и про ссылки не знает, поэтому после проверки путь ещё раз приводят через toRealPath(), который разворачивает ссылки и требует, чтобы файл существовал. Path.startsWith сравнивает сегменты, а не символы.

живой пример

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ResolveInside {
    static Path resolveInside(Path base, String userPath) throws IOException {
        Path root = base.toRealPath();
        Path candidate = root.resolve(userPath).normalize();
        if (!candidate.startsWith(root)) {
            throw new SecurityException("путь вне каталога");
        }
        Path real = candidate.toRealPath();
        if (!real.startsWith(root)) {
            throw new SecurityException("ссылка ведёт вне каталога");
        }
        return real;
    }

    public static void main(String[] args) throws Exception {
        Path shop = Files.createTempDirectory("shop");
        Path invoices = Files.createDirectories(shop.resolve("invoices"));
        Files.writeString(invoices.resolve("order-1001.txt"), "Счёт 1001");
        Files.writeString(Files.createDirectories(invoices.resolve("2026")).resolve("order-1002.txt"), "Счёт 1002");
        Files.writeString(Files.createDirectories(shop.resolve("secrets")).resolve("db.env"), "DB_PASSWORD=s3cr3t");
        Files.createSymbolicLink(invoices.resolve("shared"), shop.resolve("secrets"));

        String[] requests = {"order-1001.txt", "2026/order-1002.txt", "../secrets/db.env", "/etc/passwd",
                "../invoices-internal/payouts.csv", "shared/db.env", "order-1001.txt\0.pdf"};
        for (String request : requests) {
            String shown = request.replace("\0", "\\0");
            try {
                System.out.printf("%-34s ok: %s%n", shown, Files.readString(resolveInside(invoices, request)));
            } catch (Exception e) {
                String reason = e.getClass().getSimpleName() + " " + e.getMessage();
                System.out.printf("%-34s отказ: %s%n", shown, reason.replace("\0", "\\0"));
            }
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В Go 1.24 появился os.Root: каталог открывается один раз, а методы корня запрещают любой выход из него, как через .., так и через ссылки. filepath.IsLocal это быстрая лексическая проверка: если она вернула true, то Join(base, path) гарантированно внутри base, но про ссылки на диске она ничего не знает. Пример показывает обе: IsLocal отсекает .. и абсолютный путь, а ссылку shared ловит только os.Root.

живой пример

package main

import (
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

func resolveInside(base, userPath string) (string, error) {
	if !filepath.IsLocal(userPath) {
		return "", errors.New("путь вне каталога")
	}
	return filepath.Join(base, userPath), nil
}

func readInside(root *os.Root, userPath string) (string, error) {
	f, err := root.Open(userPath)
	if err != nil {
		return "", err
	}
	defer f.Close()
	data, err := io.ReadAll(f)
	return string(data), err
}

func main() {
	shop, _ := os.MkdirTemp("", "shop")
	defer os.RemoveAll(shop)
	write := func(rel, text string) {
		p := filepath.Join(shop, rel)
		os.MkdirAll(filepath.Dir(p), 0o755)
		os.WriteFile(p, []byte(text), 0o644)
	}
	write("invoices/order-1001.txt", "Счёт 1001")
	write("invoices/2026/order-1002.txt", "Счёт 1002")
	write("secrets/db.env", "DB_PASSWORD=s3cr3t")
	invoices := filepath.Join(shop, "invoices")
	os.Symlink(filepath.Join(shop, "secrets"), filepath.Join(invoices, "shared"))

	root, err := os.OpenRoot(invoices)
	if err != nil {
		panic(err)
	}
	defer root.Close()

	requests := []string{"order-1001.txt", "2026/order-1002.txt", "../secrets/db.env", "/etc/passwd",
		"../invoices-internal/payouts.csv", "shared/db.env", "order-1001.txt\x00.pdf"}
	for _, request := range requests {
		shown := strings.ReplaceAll(request, "\x00", `\0`)
		if _, err := resolveInside(invoices, request); err != nil {
			fmt.Printf("%-34s IsLocal: отказ, %v\n", shown, err)
			continue
		}
		text, err := readInside(root, request)
		if err != nil {
			fmt.Printf("%-34s os.Root: отказ, %v\n", shown, strings.ReplaceAll(err.Error(), "\x00", `\0`))
			continue
		}
		fmt.Printf("%-34s ok: %s\n", shown, text)
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Готовой функции «путь внутри каталога» в модуле path нет. Рабочий приём: path.resolve делает путь абсолютным, path.relative от корня до него не должен начинаться с .. и не должен быть абсолютным (на Windows так выглядит другой диск). Ссылки разворачивает fs.realpathSync, после него проверка повторяется.

живой пример

const fs = require('fs');
const os = require('os');
const path = require('path');

function isInside(root, target) {
  const rel = path.relative(root, target);
  return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
}

function resolveInside(base, userPath) {
  const root = fs.realpathSync(base);
  const candidate = path.resolve(root, userPath);
  if (!isInside(root, candidate)) throw new Error('путь вне каталога');
  const real = fs.realpathSync(candidate);
  if (!isInside(root, real)) throw new Error('ссылка ведёт вне каталога');
  return real;
}

const shop = fs.mkdtempSync(path.join(os.tmpdir(), 'shop-'));
const write = (rel, text) => {
  fs.mkdirSync(path.dirname(path.join(shop, rel)), { recursive: true });
  fs.writeFileSync(path.join(shop, rel), text);
};
write('invoices/order-1001.txt', 'Счёт 1001');
write('invoices/2026/order-1002.txt', 'Счёт 1002');
write('secrets/db.env', 'DB_PASSWORD=s3cr3t');
const invoices = path.join(shop, 'invoices');
fs.symlinkSync(path.join(shop, 'secrets'), path.join(invoices, 'shared'));

const requests = ['order-1001.txt', '2026/order-1002.txt', '../secrets/db.env', '/etc/passwd',
  '../invoices-internal/payouts.csv', 'shared/db.env', 'order-1001.txt\0.pdf'];
for (const request of requests) {
  const shown = request.replace('\0', '\\0').padEnd(34);
  try {
    console.log(`${shown} ok: ${fs.readFileSync(resolveInside(invoices, request), 'utf8')}`);
  } catch (e) {
    console.log(`${shown} отказ: ${e.code ?? e.message}`);
  }
}
fs.rmSync(shop, { recursive: true });
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Path.resolve() делает путь абсолютным, убирает .. и разворачивает ссылки. Порядок важен: is_relative_to() по документации «is string-based; it neither accesses the filesystem nor treats '..' segments specially», поэтому без resolve() путь invoices/../secrets для неё «внутри». Сегменты она при этом сравнивает целиком, и соседний invoices-internal не проходит.

живой пример

import tempfile
from pathlib import Path


def resolve_inside(base: Path, user_path: str) -> Path:
    root = base.resolve()
    candidate = (root / user_path).resolve()
    if not candidate.is_relative_to(root):
        raise PermissionError("путь вне каталога")
    return candidate


with tempfile.TemporaryDirectory() as tmp:
    shop = Path(tmp)
    for rel, text in [("invoices/order-1001.txt", "Счёт 1001"),
                      ("invoices/2026/order-1002.txt", "Счёт 1002"),
                      ("secrets/db.env", "DB_PASSWORD=s3cr3t")]:
        (shop / rel).parent.mkdir(parents=True, exist_ok=True)
        (shop / rel).write_text(text, encoding="utf-8")
    invoices = shop / "invoices"
    (invoices / "shared").symlink_to(shop / "secrets")

    requests = ["order-1001.txt", "2026/order-1002.txt", "../secrets/db.env", "/etc/passwd",
                "../invoices-internal/payouts.csv", "shared/db.env", "order-1001.txt\0.pdf"]
    for request in requests:
        shown = request.replace("\0", "\\0")
        try:
            text = resolve_inside(invoices, request).read_text(encoding="utf-8")
            print(f"{shown:34} ok: {text}")
        except (PermissionError, ValueError) as e:
            print(f"{shown:34} отказ: {type(e).__name__}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три правила вокруг этой функции. Отказывать, а не чистить: замена ../ на пустую строку превращает ....// в ../, и атака проходит со второй попытки. Декодировать ровно один раз и до проверки: если после проверки кто-то ещё раз раскодирует %252e, проверка смотрела на другую строку. Корень приводить так же, как кандидата: временный каталог на macOS лежит за ссылкой /var -> /private/var, и сравнение развёрнутого пути с неразвёрнутым корнем отвергнет всё, включая законные файлы.

Остаётся гонка между проверкой и открытием: если злоумышленник может создавать файлы внутри каталога, он подменит папку ссылкой после проверки. Блог Go разбирает этот TOCTOU-сценарий и поэтому советует os.Root, который проверяет каждый шаг при самом открытии. Для остальных языков вывод практический: каталог, куда пишут клиенты, и каталог, откуда сервер читает по пути от клиента, не должны совпадать, а лучше файлы клиентов вообще держать не на диске сервера.

Чем опасна загрузка файловспросят на собеседовании

CWE-434 «Unrestricted Upload of File with Dangerous Type»: сервер принимает файл, не проверив, что это такое, и потом сам или чужой браузер его исполняет. Что может прийти вместо фото разбитой посылки:

  • исполняемый код под видом картинки. avatar.php.jpg, avatar.pHp, shell.php%00.jpg, shell.asp:.jpg (поток NTFS) - обходы проверки расширения из шпаргалки OWASP. Если каталог загрузок отдаёт веб-сервер с интерпретатором, это удалённое выполнение кода;
  • полиглот: файл, который одновременно валидный PNG и что-то ещё, например PNG с HTML или скриптом в хвосте. Проверку подписи он проходит;
  • Content-Type от клиента. Это заявление, а не факт: OWASP прямо пишет, что он «provided by the user, and as such cannot be trusted, as it is trivial to spoof»;
  • огромный файл, zip-бомба и XML-бомба: маленький запрос, который заполняет диск или память при распаковке;
  • архив с обходом пути (zip slip): запись в архиве называется ../../app/run.sh. Snyk раскрыл эту ошибку 5 июня 2018 года сразу в тысячах проектов: «arbitrary file overwrite», которое обычно заканчивается выполнением команд;
  • SVG со скриптом. SVG это XML, и элемент <script> в нём законен. Открытый в браузере с вашего домена, он выполняется как XSS против покупателей и операторов поддержки;
  • имя файла от клиента: ../../etc/cron.d/x, CON на Windows, <img onerror=...>.png, которое потом выводится в интерфейсе поддержки без экранирования. Короче, весь предыдущий раздел плюс XSS.

Как принимать и отдавать файлы безопасноспросят на собеседовании

OWASP начинает с того, что «There is no silver bullet»: защиту собирают слоями, и каждый слой закрывает то, что пропустил предыдущий.

  1. Ограничить размер до чтения тела: на прокси и в приложении, отдельно на файл и на запрос.
  2. Список разрешённых типов, а не запрещённых: для вложения в поддержку это PNG и JPEG, больше ничего. Тип определяют по содержимому, а не по имени и не по Content-Type.
  3. Проверить подпись (magic bytes): первые байты PNG 89 50 4E 47 0D 0A 1A 0A, JPEG FF D8 FF, как в таблице сигнатур спецификации MIME Sniffing. OWASP предупреждает, что одной подписи мало, и пример ниже показывает почему.
  4. Перекодировать картинку: декодировать и сохранить заново в разрешённом формате без метаданных. Хвост полиглота при этом пропадает. Сам декодер при этом работает с недоверенным вводом, поэтому библиотеку держат свежей и с ограничениями ресурсов.
  5. Своё случайное имя: UUID или случайная строка, расширение выводится из определённого типа. Имя клиента, если оно нужно для отображения, хранится в базе как данные и экранируется при выводе.
  6. Хранить вне корня веб-сервера, а лучше на другом хосте: в объектном хранилище, где загруженный файл никто не исполнит. Как раздавать оттуда по ссылкам со сроком, разобрано в модели S3.
  7. Отдавать с правильными заголовками: Content-Type из сохранённого определённого типа, X-Content-Type-Options: nosniff и Content-Disposition: attachment.
  8. Антивирус или CDR (разбор и пересборка документа) как дополнительный слой для PDF и офисных файлов, если сервис их принимает.

Проверка подписи на тех же пяти загрузках, что пришли бы в поддержку: настоящий PNG, настоящий JPEG, PHP под именем avatar.png, SVG со скриптом под именем banner.jpg и PNG-полиглот со скриптом в хвосте.

живой пример

import java.nio.charset.StandardCharsets;
import java.util.Arrays;

public class SniffUpload {
    static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
    static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};

    static String sniffImage(byte[] data) {
        if (startsWith(data, PNG)) return "image/png";
        if (startsWith(data, JPEG)) return "image/jpeg";
        return null;
    }

    static boolean startsWith(byte[] data, byte[] magic) {
        return data.length >= magic.length && Arrays.equals(data, 0, magic.length, magic, 0, magic.length);
    }

    static byte[] concat(byte[] head, String tail) {
        byte[] t = tail.getBytes(StandardCharsets.ISO_8859_1);
        byte[] out = Arrays.copyOf(head, head.length + t.length);
        System.arraycopy(t, 0, out, head.length, t.length);
        return out;
    }

    public static void main(String[] args) {
        Object[][] uploads = {
                {"avatar.png", "image/png", concat(PNG, "IHDR")},
                {"photo.jpg", "image/jpeg", concat(JPEG, "àJFIF")},
                {"avatar.png", "image/png", concat(new byte[0], "<?php system($_GET['c']); ?>")},
                {"banner.jpg", "image/jpeg", concat(new byte[0], "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>")},
                {"cat.png", "image/png", concat(PNG, "<script>alert(1)</script>")},
        };
        for (Object[] u : uploads) {
            String sniffed = sniffImage((byte[]) u[2]);
            String verdict = sniffed == null ? "отказ: подпись не PNG и не JPEG" : "принят как " + sniffed;
            System.out.printf("%-11s заявлен %-10s -> %s%n", u[0], u[1], verdict);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В Go подпись проверяет стандартная http.DetectContentType: она реализует алгоритм спецификации MIME Sniffing по первым 512 байтам. Ответ сверяют со списком разрешённых типов.

живой пример

package main

import (
	"fmt"
	"net/http"
)

var allowedImages = map[string]string{"image/png": ".png", "image/jpeg": ".jpg"}

func main() {
	png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
	jpeg := []byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00")
	uploads := []struct {
		name, claimed string
		data          []byte
	}{
		{"avatar.png", "image/png", png},
		{"photo.jpg", "image/jpeg", jpeg},
		{"avatar.png", "image/png", []byte("<?php system($_GET['c']); ?>")},
		{"banner.jpg", "image/jpeg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
		{"cat.png", "image/png", append(png, "<script>alert(1)</script>"...)},
	}
	for _, u := range uploads {
		sniffed := http.DetectContentType(u.data)
		verdict := "отказ: на деле " + sniffed
		if ext, ok := allowedImages[sniffed]; ok {
			verdict = "принят как " + sniffed + ", храним с " + ext
		}
		fmt.Printf("%-11s заявлен %-10s -> %s\n", u.name, u.claimed, verdict)
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff]);

function sniffImage(data) {
  if (data.subarray(0, PNG.length).equals(PNG)) return 'image/png';
  if (data.subarray(0, JPEG.length).equals(JPEG)) return 'image/jpeg';
  return null;
}

const uploads = [
  ['avatar.png', 'image/png', Buffer.concat([PNG, Buffer.from('IHDR')])],
  ['photo.jpg', 'image/jpeg', Buffer.concat([JPEG, Buffer.from([0xe0]), Buffer.from('JFIF')])],
  ['avatar.png', 'image/png', Buffer.from("<?php system($_GET['c']); ?>")],
  ['banner.jpg', 'image/jpeg', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')],
  ['cat.png', 'image/png', Buffer.concat([PNG, Buffer.from('<script>alert(1)</script>')])],
];
for (const [name, claimed, data] of uploads) {
  const sniffed = sniffImage(data);
  const verdict = sniffed ? `принят как ${sniffed}` : 'отказ: подпись не PNG и не JPEG';
  console.log(`${name.padEnd(11)} заявлен ${claimed.padEnd(10)} -> ${verdict}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

PNG = b"\x89PNG\r\n\x1a\n"
JPEG = b"\xff\xd8\xff"


def sniff_image(data: bytes) -> str | None:
    if data.startswith(PNG):
        return "image/png"
    if data.startswith(JPEG):
        return "image/jpeg"
    return None


uploads = [
    ("avatar.png", "image/png", PNG + b"IHDR"),
    ("photo.jpg", "image/jpeg", JPEG + b"\xe0JFIF"),
    ("avatar.png", "image/png", b"<?php system($_GET['c']); ?>"),
    ("banner.jpg", "image/jpeg", b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
    ("cat.png", "image/png", PNG + b"<script>alert(1)</script>"),
]
for name, claimed, data in uploads:
    sniffed = sniff_image(data)
    verdict = f"принят как {sniffed}" if sniffed else "отказ: подпись не PNG и не JPEG"
    print(f"{name:11} заявлен {claimed:10} -> {verdict}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

PHP и SVG отсеяны, хотя заявляли картинку и в расширении, и в Content-Type. А cat.png принят: подпись честная, скрипт в хвосте. Его обезвреживают следующие слои: перекодирование выбросит хвост, а отдача с nosniff и attachment не даст браузеру показать файл как HTML. По MDN, с nosniff браузер берёт Content-Type как есть и не угадывает тип по содержимому, а Content-Disposition: attachment просит скачать файл вместо показа на странице.

Как это выглядит в обработчике вложений к обращению: предел размера, подпись, случайный ключ в объектном хранилище, запись в базе и отдача по идентификатору. Код без запуска, имена методов из документации библиотек.

Предел для multipart задают свойствами spring.servlet.multipart.max-file-size и max-request-size, по умолчанию 1MB и 10MB. Перекодировать можно ImageIO.read и ImageIO.write из JDK.

@RestController
@RequestMapping("/tickets/{ticketId}/attachments")
class TicketAttachmentController {
    private static final long MAX_BYTES = 5 * 1024 * 1024;
    private final S3Client s3;
    private final AttachmentRepository attachments;
    private final String bucket;

    TicketAttachmentController(S3Client s3, AttachmentRepository attachments, @Value("${storage.bucket}") String bucket) {
        this.s3 = s3;
        this.attachments = attachments;
        this.bucket = bucket;
    }

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    ResponseEntity<Void> upload(@PathVariable long ticketId, @RequestParam("file") MultipartFile file) throws IOException {
        if (file.getSize() > MAX_BYTES) {
            return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build();
        }
        byte[] data = file.getBytes();
        String type = ImageSniffer.sniffImage(data);
        if (type == null) {
            return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
        }
        String key = "attachments/" + UUID.randomUUID();
        s3.putObject(PutObjectRequest.builder().bucket(bucket).key(key).contentType(type).build(),
                RequestBody.fromBytes(data));
        long id = attachments.save(ticketId, key, type);
        return ResponseEntity.created(URI.create("/tickets/" + ticketId + "/attachments/" + id)).build();
    }

    @GetMapping("/{attachmentId}")
    ResponseEntity<byte[]> download(@PathVariable long ticketId, @PathVariable long attachmentId) {
        Attachment a = attachments.find(ticketId, attachmentId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        byte[] body = s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key(a.key()).build()).asByteArray();
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(a.contentType()))
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment().filename("attachment-" + attachmentId).build().toString())
                .header("X-Content-Type-Options", "nosniff")
                .body(body);
    }
}

http.MaxBytesReader обрывает чтение тела сверх предела. Перекодировать можно стандартными image/png и image/jpeg: image.Decode, затем png.Encode или jpeg.Encode.

const maxAttachmentBytes = 5 << 20

func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes)
	file, _, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "файл не принят", http.StatusRequestEntityTooLarge)
		return
	}
	defer file.Close()
	data, err := io.ReadAll(file)
	if err != nil {
		http.Error(w, "файл не принят", http.StatusRequestEntityTooLarge)
		return
	}
	contentType := http.DetectContentType(data)
	if _, ok := allowedImages[contentType]; !ok {
		http.Error(w, "нужен PNG или JPEG", http.StatusUnsupportedMediaType)
		return
	}
	key := "attachments/" + rand.Text()
	_, err = h.s3.PutObject(r.Context(), &s3.PutObjectInput{
		Bucket: aws.String(h.bucket), Key: aws.String(key),
		Body: bytes.NewReader(data), ContentType: aws.String(contentType),
	})
	if err != nil {
		http.Error(w, "хранилище недоступно", http.StatusBadGateway)
		return
	}
	id, err := h.repo.Save(r.Context(), chi.URLParam(r, "ticketID"), key, contentType)
	if err != nil {
		http.Error(w, "ошибка сохранения", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Location", fmt.Sprintf("/tickets/%s/attachments/%s", chi.URLParam(r, "ticketID"), id))
	w.WriteHeader(http.StatusCreated)
}

func (h *AttachmentHandler) Download(w http.ResponseWriter, r *http.Request) {
	a, err := h.repo.Find(r.Context(), chi.URLParam(r, "ticketID"), chi.URLParam(r, "attachmentID"))
	if err != nil {
		http.NotFound(w, r)
		return
	}
	obj, err := h.s3.GetObject(r.Context(), &s3.GetObjectInput{Bucket: aws.String(h.bucket), Key: aws.String(a.Key)})
	if err != nil {
		http.Error(w, "хранилище недоступно", http.StatusBadGateway)
		return
	}
	defer obj.Body.Close()
	w.Header().Set("Content-Type", a.ContentType)
	w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": "attachment-" + a.ID}))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	io.Copy(w, obj.Body)
}

У multer все пределы по умолчанию Infinity, поэтому limits.fileSize задают явно; превышение приходит в обработчик ошибок с кодом LIMIT_FILE_SIZE, где отвечают 413. Перекодируют обычно библиотекой sharp.

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: 1 } });

router.post('/tickets/:ticketId/attachments', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).end();
  const contentType = sniffImage(req.file.buffer);
  if (!contentType) return res.status(415).end();
  const key = `attachments/${crypto.randomUUID()}`;
  await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: req.file.buffer, ContentType: contentType }));
  const id = await attachments.save(req.params.ticketId, key, contentType);
  res.status(201).location(`/tickets/${req.params.ticketId}/attachments/${id}`).end();
});

router.get('/tickets/:ticketId/attachments/:attachmentId', async (req, res) => {
  const a = await attachments.find(req.params.ticketId, req.params.attachmentId);
  if (!a) return res.status(404).end();
  const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: a.key }));
  res.set({
    'Content-Type': a.contentType,
    'Content-Disposition': `attachment; filename="attachment-${a.id}"`,
    'X-Content-Type-Options': 'nosniff',
  });
  obj.Body.pipe(res);
});

FastAPI разбирает multipart до вызова обработчика, поэтому read(MAX_BYTES + 1) защищает память обработчика, но не диск: предел тела ставят ещё и на прокси, у nginx это client_max_body_size с умолчанием 1m. Перекодируют обычно библиотекой Pillow.

MAX_BYTES = 5 * 1024 * 1024


@router.post("/tickets/{ticket_id}/attachments", status_code=201)
async def upload_attachment(ticket_id: int, file: UploadFile):
    data = await file.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise HTTPException(status_code=413)
    content_type = sniff_image(data)
    if content_type is None:
        raise HTTPException(status_code=415)
    key = f"attachments/{uuid.uuid4()}"
    s3.put_object(Bucket=BUCKET, Key=key, Body=data, ContentType=content_type)
    attachment_id = await attachments.save(ticket_id, key, content_type)
    return {"id": attachment_id}


@router.get("/tickets/{ticket_id}/attachments/{attachment_id}")
async def download_attachment(ticket_id: int, attachment_id: int):
    a = await attachments.find(ticket_id, attachment_id)
    if a is None:
        raise HTTPException(status_code=404)
    body = s3.get_object(Bucket=BUCKET, Key=a.key)["Body"].read()
    return Response(body, media_type=a.content_type, headers={
        "Content-Disposition": f'attachment; filename="attachment-{attachment_id}"',
        "X-Content-Type-Options": "nosniff",
    })

В обоих обработчиках attachments.find ищет по паре «обращение и вложение», и до него стоит проверка, что пользователь вправе видеть это обращение: покупатель свои, оператор поддержки назначенные ему. Без этого случайный ключ не спасает, ведь идентификатор вложения клиент знает.

Как проверить

Тест функции пути. Таблица запросов из примера выше превращается в параметризованный тест: обычный и вложенный файл проходят; ../, ..\, %2e%2e%2f после декодирования, %252e%252e%252f, абсолютный путь, соседний каталог с общим префиксом, ссылка наружу и нулевой байт отвергаются. Тест на соседний каталог ловит самую частую ошибку, строковый префикс, и его стоит держать даже тогда, когда «и так понятно».

Тест загрузки. Через настоящий HTTP-запрос: файл больше предела получает 413, PHP с Content-Type: image/png получает 415, имя ../../x.png никак не влияет на ключ в хранилище, а ответ на скачивание содержит X-Content-Type-Options: nosniff и Content-Disposition: attachment.

Ревью. Искать вызовы файловых API (открыть, записать, удалить, распаковать, отдать файл) и прослеживать, откуда пришла строка пути: параметр запроса, имя multipart-файла, имя записи в архиве, поле из базы, которое когда-то записал клиент. Каждый такой поток либо идёт через resolveInside, либо заменён идентификатором. SAST-инструменты из гайда по безопасности ищут ровно этот поток от запроса к файловому вызову; находки по нему не глушат без разбора.

Типичные ошибки

  • Строковый префикс вместо сравнения сегментов: startsWith("/srv/invoices") пропускает /srv/invoices-internal.
  • Проверка до декодирования или двойное декодирование: проверяли одну строку, открыли другую.
  • Чистка вместо отказа: удаление ../ один раз обходится через ....//.
  • Лексическая нормализация без учёта ссылок: normalize, Clean, normpath не знают про символические ссылки на диске.
  • Вера расширению и Content-Type: оба приходят от клиента и подделываются одной строкой.
  • Имя файла клиента как ключ хранения: обход пути, перезапись чужих файлов и XSS в интерфейсе одной ошибкой.
  • Загрузки в каталоге, который отдаёт веб-сервер: один пропущенный .php или .jsp превращает загрузку в выполнение кода.
  • Предел размера после чтения тела в память: сервер уже упал, проверять поздно.
  • Распаковка архива без проверки имён записей и суммарного размера: zip slip и zip-бомба в одной строке extractAll.
Дополнительно: при первом чтении можно пропустить

Глубже: архивы, zip slip и zip-бомбырасширенное

Имя записи в архиве это такая же строка от клиента, как параметр запроса, только её не видно в логах. При распаковке каждое имя проходит через ту же resolveInside, а не просто склеивается с целевым каталогом. OWASP для этого отсылает к правилу SEI CERT IDS04-J «Safely extract files from ZipInputStream».

Стандартные библиотеки ведут себя по-разному. В Python zipfile.extract сам убирает ведущие слеши и все компоненты .. из имён, а у tarfile с версии 3.14 фильтр по умолчанию data, который запрещает ссылки на абсолютные пути и выход за целевой каталог. До 3.14 поведение было равно fully_trusted, и в старом коде фильтр передают явно. Документация при этом всё равно предупреждает: не распаковывать архивы из недоверенных источников без проверки.

Против zip-бомбы проверяют не размер архива, а то, что из него получится: считают уже распакованные байты и прерывают распаковку, как только сумма превысила предел, и ограничивают число записей. OWASP формулирует так: предел размера должен учитываться «after file decompression is conducted». Размер из заголовка записи тоже пишет клиент, поэтому верить ему нельзя, считать нужно по факту чтения. А лучше вообще не принимать архивы там, где нужны картинки: OWASP прямо не рекомендует ZIP, потому что внутри может быть что угодно.

Глубже: почему отдача важнее проверки при загрузкерасширенное

Проверка при загрузке когда-нибудь пропустит файл: полиглот, новый формат, ошибку в декодере. Отдача решает, что браузер с этим файлом сделает, и это последний рубеж. Для вложений и документов ответ всегда несёт Content-Disposition: attachment, точный Content-Type из сохранённого определённого типа (не из запроса) и nosniff. Тогда HTML внутри PNG останется скачанным файлом, а не страницей на вашем домене с доступом к cookie покупателя.

Картинки витрины, которые показывают на странице, отдают иначе: только после перекодирования, с nosniff, и лучше с отдельного домена, где нет cookie сессии маркетплейса. SVG от пользователей на витрину не пускают вовсе либо растеризуют в PNG при загрузке. Детали заголовков безопасности со стороны браузера разобраны в статье про безопасность браузера и сети.

Коротко

  • Обход пути (CWE-22, CWE-73): строка клиента становится путём, и ../, абсолютный путь, кодирование или ссылка выводят за каталог.
  • Лучшее лечение: не строить путь из ввода, а сопоставлять идентификатор с файлом и проверять владение.
  • Если путь нужен: декодировать один раз, привести к каноническому виду вместе со ссылками, сравнить по сегментам и отказать, а не чистить. Go: os.Root (1.24) или filepath.IsLocal; Python: resolve() и только потом is_relative_to(); Node: path.resolve плюс path.relative; Java: normalize, startsWith по сегментам и toRealPath.
  • Загрузка (CWE-434): расширение, имя и Content-Type пишет клиент. Тип определяют по подписи из списка разрешённых, картинки перекодируют.
  • Своё случайное имя, хранение вне корня веб-сервера или в объектном хранилище, предел размера до чтения тела.
  • Архивы: каждое имя записи через проверку пути, предел по распакованным байтам и числу записей.
  • Отдача: Content-Disposition: attachment, точный Content-Type, X-Content-Type-Options: nosniff; антивирус и CDR как дополнительный слой.

Что почитать дальше