open atlas
↑ К треку
Next.js с нуля до senior NEXT · 04 · 03

server-only и секреты: граф модулей, NEXT_PUBLIC_ и что на самом деле ловят taint-API

Секреты утекают через клиентский граф модулей и сериализованные пропсы, а не только через сеть. import server-only превращает неверный импорт в ошибку сборки; NEXT_PUBLIC_-переменные навсегда зашиваются в бандл; taint-API — экспериментальная растяжка, не граница безопасности.

NEXT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Первой находке пентест-отчёта не нужна цепочка эксплойтов: «API-секрет присутствует в публичном JavaScript-бандле». Тестировщик греппит задеплоенные чанки под _next/static/ — и вот он: продакшен-ключ API, открытым текстом, отгружаемый каждому посетителю последние три месяца. Git-археология находит момент: клиентскому компоненту понадобился внутренний API, fetch упал с undefined-ключом, и разработчик «починил», переименовав API_SECRET в NEXT_PUBLIC_API_SECRET. Fetch позеленел, PR назывался «fix env var not loading», ревью одобрило за минуты. Никто не был безрассуден — разработчик сделал то, что подсказывало сообщение об ошибке, а сборка нигде не предупредила, что NEXT_PUBLIC_ значит зашить эту строку в бандл во время сборки, навсегда. Починка заняла час. Ротация ключа, аудит трёх месяцев логов доступа и разговор с клиентами о disclosure заняли три недели. Этот урок — о том, как сделать такое переименование невозможным.

Граф модулей решает, что уедет в браузер

Если вы когда-нибудь задавались вопросом, как секрет попадает в публичный JS-бандл, когда вы никогда намеренно его туда не клали, — ответ в правиле транзитивных импортов. Понять его значит навсегда изменить то, как вы структурируете модули, работающие с учётными данными.

В App Router «серверный код» и «клиентский код» — не отдельные папки, а области одного графа модулей, разделённого директивой 'use client'. Граф модулей (module graph) — это граф зависимостей, по которому бандлер определяет, что войдёт в клиентскую сборку. Файл с 'use client' и всё, что он импортирует, транзитивно, обязано собираться для браузера. Эта транзитивная оговорка — место, где живут утечки: вы импортируете один невинный хелпер из lib/billing.ts в клиентский компонент, и бандлер втягивает весь модуль — включая части, трогающие секреты, — в клиентский граф. Топ-левел код модуля теперь исполняется в браузере; любой строковый литерал в нём уезжает дословно в публичный чанк.

Переменные окружения подчиняются двум разным правилам, и их смешение — классическая ошибка. Обычные серверные переменные (process.env.STRIPE_SECRET_KEY) читаются в рантайме на сервере; если такой код оказался в клиентском бандле, в браузере значение — undefined: падение или тихий сбой, конфузно, но не утечка. Переменные с префиксом NEXT_PUBLIC_ — противоположность: они зашиваются во время сборки — бандлер строково заменяет process.env.NEXT_PUBLIC_X на литеральное значение везде, где оно встречается. Три следствия: значение видно любому, кто откроет devtools; его нельзя ротировать без пересборки и редеплоя (старое значение живёт в закешированных чанках до их истечения); и префикс — дверь в одну сторону: секрет, однажды уехавший под NEXT_PUBLIC_, скомпрометирован, точка, и единственное лекарство — ротация. Префикс — для по-настоящему публичной конфигурации: id аналитики, публичные URL API. Всё, что вы не вставили бы в твит, префикса не получает.

server-only: растяжка на этапе сборки

Защита — не бдительность, а превращение неверного импорта в провал сборки. Пакет server-only не содержит рантайм-кода; его трюк — в export conditions в package.json: при разрешении в серверном контексте он разрешается в пустой модуль, а когда его пытается включить клиентский бандл — в модуль, бросающий ошибку сборки. Одна строка импорта наверху любого модуля, который никогда не должен достичь браузера, превращает «разработчик импортировал не тот файл» из тихой утечки в красную сборку:

// lib/billing.ts
import 'server-only';                // клиентский импорт этого модуля = ошибка сборки
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function chargeCustomer(customerId: string, amountCents: number) {
  return stripe.charges.create({ customer: customerId, amount: amountCents, currency: 'usd' });
}

// Невинно выглядящий хелпер, соблазняющий на клиентский импорт, — теперь за забором:
export function formatAmount(cents: number) {
  return (cents / 100).toFixed(2);
}

Дисциплина, делающая это дешёвым: ставьте import 'server-only' в каждый модуль, читающий секреты, трогающий базу или оборачивающий привилегированный SDK, — рефлекторно, как пишете типы. Чистые форматирующие хелперы, законно нужные клиенту, живут в собственном lib/format.ts без секретоносных соседей, чтобы никого не тянуло импортировать через забор. Зеркальный пакет client-only существует для обратной ошибки (код с браузерными API, втянутый в серверный рендеринг). Цена растяжки — одна строка импорта; отказ, который она конвертирует, — трёхнедельный инцидент из Hook в тридцатисекундную ошибку сборки, указывающую на провинившуюся цепочку импортов.

Викторина

Fetch в клиентском компоненте падает: process.env.API_SECRET в браузере undefined. Разработчик переименовывает её в NEXT_PUBLIC_API_SECRET, и fetch работает. Что на самом деле произошло?

Утечки данных: сериализованные пропсы, DTO и что реально делает taint

Код — один канал утечки; данные — второй. Каждый проп, который вы передаёте из серверного компонента в клиентский, сериализуется в RSC-payload — встраивается в HTML-документ и в ответы последующих навигаций, полностью видимый в исходнике страницы. Классическая форма инцидента: getUser() возвращает целую строку базы, серверный компонент передаёт user в клиентский <ProfileCard> — и passwordHash, mfaSecret и stripeCustomerId теперь в исходнике страницы для кого угодно: ни взлома, ни эксплойта, просто сериализация честно делает свою работу над данными, которым нельзя было пересекать границу. Структурное лекарство — дисциплина DTO из доков по аутентификации: функции доступа к данным возвращают явно сконструированные объекты с полями, нужными UI, и никогда — сырую строку. Тогда утечка требует, чтобы кто-то добавил чувствительное поле в DTO намеренно, вместо того чтобы все помнили вычищать его везде.

Taint-API React — вторая растяжка для этого канала, и она заслуживает честного описания. experimental_taintObjectReference(message, user) заставляет React бросить исключение при рендере, если именно эта ссылка на объект передана клиентскому компоненту; experimental_taintUniqueValue(message, lifetime, secret) делает то же для конкретного примитивного значения. Пределы важны не меньше возможностей: оба API экспериментальны (за флагом taint); пометка объекта отслеживает ссылку, так что { ...user } создаёт непомеченную копию, проходящую насквозь; пометка значения отслеживает точное значение, так что любая производная — верхний регистр, base64, подстрока — ускользает. И оба срабатывают в рантайме, не на сборке: нарушение taint на редко рендеримой админ-странице найдётся, когда та отрендерится, а не когда смержится. Taint — это defense-in-depth, ловящий честную ошибку прямой передачи помеченного объекта; это не граница безопасности, и DTO он не заменяет — он их страхует.

Почему это работает

Почему фреймворк просто не блокирует чувствительные данные автоматически? Потому что слой сериализации не может знать, что чувствительно: passwordHash и productDescription — обе строки, и разницу знает только ваша доменная модель. Отсюда слоистый дизайн, где каждый слой ловит то, что не может предыдущий: server-only огораживает код на сборке (дёшево, тотально, но лишь для модулей, которые вы не забыли пометить); DTO формируют данные структурно (поля нет — утекать нечему); taint ловит остаточный случай прямой передачи помеченного объекта (рантайм, по ссылке, экспериментально). Три несовершенные растяжки слоями приближают границу, которую не даёт ни один механизм в одиночку.

Викторина

Команда помечает объект пользователя через experimental_taintObjectReference сразу после чтения из БД. Затем серверный компонент передаёт { ...user } клиентскому компоненту. Что произойдёт?

Вспомните перед уходом
  1. 01
    Каковы два разных канала утечки серверных секретов в App Router и что стережёт каждый?
  2. 02
    Что именно делают NEXT_PUBLIC_ и taint-API и каковы их жёсткие пределы?
Итог

В App Router нет стены между серверным и клиентским кодом — только один граф модулей, разделённый ‘use client’, и правило бандлера транзитивно: всё, что импортирует клиентский файл, уезжает. Это первый канал утечки: импортируйте один невинный хелпер из модуля, который заодно конструирует Stripe-клиент, — и весь модуль вступает в публичный бандл. Переменные окружения живут в двух режимах: обычные серверные читаются в рантайме (в браузере undefined — падение, не утечка), а NEXT_PUBLIC_ зашиваются при сборке строковыми литералами: видимы всем, не ротируются без пересборки, и это дверь в одну сторону — секрет, уехавший под префиксом, скомпрометирован по определению, как показывает инцидент с переименованием ради зелёного fetch. Страж кодового канала — import ‘server-only’ в каждом модуле, трогающем секреты, базы или привилегированные SDK: пустой модуль на сервере, ошибка сборки в любом клиентском графе — трёхнедельный инцидент с ротацией ключа превращается в тридцатисекундный провал сборки. Второй канал — данные: пропсы из серверных компонентов в клиентские сериализуются в RSC-payload, встроенный в страницу, — передайте сырую строку user, и passwordHash окажется в view-source без всякого эксплойта. Структурный страж — дисциплина DTO: доступ к данным возвращает явно сконструированные объекты, и для утечки нужно намеренно добавить поле, а не забыть его вычистить. Taint-API — experimental_taintObjectReference и taintUniqueValue — честная растяжка сверху: бросают исключение при рендере, когда точная помеченная ссылка или значение пересекает границу, но копии, spread-ы и производные от них ускользают, им нужен экспериментальный флаг, и срабатывают они в рантайме, не на сборке. Теперь, когда тянетесь к хелперу из серверного модуля внутри клиентского компонента, вы остановитесь — и именно эта пауза и есть весь смысл понимания транзитивного правила. Огородить код, оформить данные, taint как страховка — в этом порядке.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.