В маркетплейсе файлы ходят в обе стороны. Покупатель скачивает счёт по заказу, продавец загружает фото товара, покупатель прикладывает к обращению в поддержку снимок разбитой посылки. Каждый такой обмен открывает две двери: клиент может указать, какой файл прочитать или записать, и может решить, что окажется внутри файла. Первая дверь называется обходом пути, вторая опасной загрузкой.
Как устроен обход пути и чем грозитспросят на собеседовании
Обход пути (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»: защиту собирают слоями, и каждый слой закрывает то, что пропустил предыдущий.
- Ограничить размер до чтения тела: на прокси и в приложении, отдельно на файл и на запрос.
- Список разрешённых типов, а не запрещённых: для вложения в поддержку это PNG и JPEG, больше ничего. Тип определяют по содержимому, а не по имени и не по
Content-Type. - Проверить подпись (magic bytes): первые байты PNG
89 50 4E 47 0D 0A 1A 0A, JPEGFF D8 FF, как в таблице сигнатур спецификации MIME Sniffing. OWASP предупреждает, что одной подписи мало, и пример ниже показывает почему. - Перекодировать картинку: декодировать и сохранить заново в разрешённом формате без метаданных. Хвост полиглота при этом пропадает. Сам декодер при этом работает с недоверенным вводом, поэтому библиотеку держат свежей и с ограничениями ресурсов.
- Своё случайное имя: UUID или случайная строка, расширение выводится из определённого типа. Имя клиента, если оно нужно для отображения, хранится в базе как данные и экранируется при выводе.
- Хранить вне корня веб-сервера, а лучше на другом хосте: в объектном хранилище, где загруженный файл никто не исполнит. Как раздавать оттуда по ссылкам со сроком, разобрано в модели S3.
- Отдавать с правильными заголовками:
Content-Typeиз сохранённого определённого типа,X-Content-Type-Options: nosniffиContent-Disposition: attachment. - Антивирус или 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 как дополнительный слой.
Что почитать дальше
- Где ставить проверку: слои проверки и заголовки безопасности.
- Владение ресурсом и ABAC: чужой файл по чужому идентификатору.
- PII и секреты: что делать, если
db.envвсё-таки утёк. - Object storage (S3): где хранить загрузки и как раздавать их по ссылкам со сроком.
- Безопасность браузера и сети: XSS и заголовки со стороны браузера.
- Гайд по безопасности: SAST и проверки в сборке.