Безопасное хранение данных в кеше

Кеш в TanStack Query представляет собой централизованное хранилище данных, полученных с сервера. Все результаты запросов сохраняются внутри QueryCache, а доступ к ним осуществляется через QueryClient.

При работе с кешем необходимо учитывать, что данные:

  • могут содержать конфиденциальную информацию;
  • существуют в памяти браузера;
  • потенциально доступны через DevTools;
  • могут сохраняться между вкладками;
  • иногда сериализуются в LocalStorage, SessionStorage или IndexedDB;
  • способны переживать перезагрузку страницы при использовании persistence-механизмов.

Безопасность кеша становится особенно важной в приложениях с:

  • авторизацией;
  • банковскими операциями;
  • административными панелями;
  • персональными данными;
  • JWT-токенами;
  • медицинскими данными;
  • корпоративными интерфейсами.

Какие данные нельзя хранить в кеше

Основная ошибка — помещение чувствительных данных в query cache.

Особенно опасно хранить:

  • access token;
  • refresh token;
  • пароли;
  • PIN-коды;
  • CVV банковских карт;
  • приватные ключи;
  • секретные API-ключи;
  • session identifiers;
  • персональные документы;
  • платежные данные.

Плохой пример:

useQuery({
    queryKey: ['auth'],
    queryFn: async () => {
        return {
            accessToken: 'jwt-token',
            refreshToken: 'refresh-token',
            password: '123456'
        }
    }
})

Даже если приложение работает локально, данные:

  • видны в React Query Devtools;
  • могут быть извлечены через XSS;
  • остаются в памяти вкладки;
  • могут утечь через persistence.

Разделение публичных и приватных данных

Безопасная архитектура начинается с разделения данных по уровню чувствительности.

Подход с разделением слоёв

Тип данных Где хранить
Access token HttpOnly cookie
Refresh token HttpOnly cookie
UI-state React state
Данные API TanStack Query
Форма логина Local component state
Настройки интерфейса LocalStorage

Хранение токенов в кеше TanStack Query создаёт риск XSS-атак.

При использовании HttpOnly cookie:

  • JavaScript не имеет доступа к токену;
  • XSS не может прочитать cookie;
  • браузер автоматически прикрепляет cookie к запросу;
  • снижается вероятность утечки токена.

Небезопасный вариант:

queryClient.setQueryData(['token'], accessToken)

Безопасный вариант:

await fetch('/api/profile', {
    credentials: 'include'
})

В этом случае токен вообще не присутствует в JavaScript-коде.


Очистка кеша при logout

После выхода пользователя необходимо полностью удалять приватные данные из кеша.

Если этого не сделать:

  • следующий пользователь может увидеть старые данные;
  • кеш сохранит предыдущую сессию;
  • произойдёт утечка информации между аккаунтами.

Правильный logout:

const logout = async () => {
    await api.logout()

    queryClient.clear()
}

Разница между clear, resetQueries и removeQueries

clear()

Полностью очищает весь кеш.

queryClient.clear()

Удаляется:

  • query cache;
  • mutation cache;
  • observers.

Используется при logout.


removeQueries()

Удаляет конкретные запросы.

queryClient.removeQueries({
    queryKey: ['profile']
})

Подходит для удаления чувствительных данных.


resetQueries()

Сбрасывает состояние запросов.

queryClient.resetQueries({
    queryKey: ['profile']
})

Не гарантирует полного удаления данных.


Минимизация времени жизни кеша

Чем дольше данные живут в памяти, тем выше риск утечки.

TanStack Query предоставляет несколько механизмов управления временем хранения.


staleTime и безопасность

staleTime определяет, как долго данные считаются актуальными.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    staleTime: 0
})

Для чувствительных данных рекомендуется минимальный staleTime.

Особенно для:

  • профиля пользователя;
  • баланса;
  • административных данных;
  • финансовых операций.

gcTime (cacheTime) и удаление данных

gcTime определяет, как долго данные остаются в памяти после потери подписчиков.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    gcTime: 0
})

При gcTime: 0 данные удаляются сразу после размонтирования компонента.

Это полезно для:

  • страниц оплаты;
  • временных токенов;
  • одноразовых операций;
  • приватных разделов.

Отключение persistence для приватных данных

Persistence позволяет сохранять кеш между перезагрузками страницы.

Обычно используется:

  • LocalStorage;
  • SessionStorage;
  • IndexedDB.

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


Опасность LocalStorage

LocalStorage:

  • доступен JavaScript;
  • уязвим для XSS;
  • не шифруется;
  • хранится длительное время.

Плохой пример:

persistQueryClient({
    queryClient,
    persister
})

Если в кеше находятся персональные данные, они окажутся в LocalStorage.


Исключение приватных query из persistence

TanStack Query позволяет фильтровать запросы.

persistQueryClient({
    queryClient,
    persister,
    dehydrateOptions: {
        shouldDehydrateQuery: (query) => {
            return query.queryKey[0] !== 'private'
        }
    }
})

Теперь запросы с ключом private не сохраняются.


Использование meta для маркировки чувствительных запросов

Удобная стратегия — маркировать приватные query через meta.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    meta: {
        sensitive: true
    }
})

Фильтрация:

shouldDehydrateQuery: (query) => {
    return !query.meta?.sensitive
}

Такой подход особенно полезен в крупных приложениях.


Изоляция кеша между пользователями

Критическая проблема — повторное использование кеша между аккаунтами.

Сценарий:

  1. Пользователь A вошёл в систему.
  2. Данные закешировались.
  3. Пользователь вышел.
  4. Пользователь B вошёл.
  5. Старые данные остались в кеше.

Использование userId в queryKey

Правильный подход:

useQuery({
    queryKey: ['profile', userId],
    queryFn: fetchProfile
})

Теперь кеш разделяется между пользователями.

Неправильно:

queryKey: ['profile']

Правильно:

queryKey: ['profile', userId]

Инвалидация данных при смене пользователя

После авторизации нового пользователя необходимо инвалидировать предыдущий кеш.

await queryClient.invalidateQueries()

Либо полностью очищать:

queryClient.clear()

Защита от утечек через Devtools

React Query Devtools показывают содержимое кеша.

В production-среде Devtools необходимо отключать.

Плохо:

<ReactQueryDevtools />

Безопаснее:

{process.env.NODE_ENV === 'development' && (
    <ReactQueryDevtools />
)}

SSR и утечка данных

При Server-Side Rendering данные могут попасть в HTML страницы.

Особенно опасно:

  • Next.js hydration;
  • dehydrate/hydrate;
  • server cache serialization.

Опасность dehydrate()

dehydrate() сериализует кеш.

const dehydratedState = dehydrate(queryClient)

Если внутри находятся приватные данные, они попадут:

  • в HTML;
  • в page source;
  • в hydration payload.

Фильтрация SSR-кеша

Безопасный вариант:

dehydrate(queryClient, {
    shouldDehydrateQuery: (query) => {
        return !query.meta?.sensitive
    }
})

Шифрование persistence-хранилища

Если persistence всё же необходим, данные желательно шифровать.

Например:

const encrypted = encrypt(JSON.stringify(cache))

Однако важно понимать:

  • ключ шифрования всё равно присутствует в приложении;
  • XSS способен получить доступ к ключу;
  • клиентское шифрование не является абсолютной защитой.

Шифрование снижает риск случайной компрометации, но не решает проблему полностью.


Ограничение объёма кешируемых данных

Не следует помещать в кеш избыточные объёмы информации.

Плохо:

return {
    user,
    tokens,
    permissions,
    auditLogs,
    paymentHistory,
    internalSecrets
}

Лучше:

return {
    id: user.id,
    name: user.name,
    avatar: user.avatar
}

Минимизация данных уменьшает:

  • поверхность атаки;
  • объём утечки;
  • размер persistence;
  • риск сериализации секретов.

Sanitization серверных ответов

Сервер не должен отправлять лишние поля.

Даже если frontend их не использует, они попадут в кеш.

Опасный API-response:

{
    "id": 1,
    "name": "Alex",
    "passwordHash": "...",
    "internalRole": "root",
    "secretKey": "..."
}

Безопасный response:

{
    "id": 1,
    "name": "Alex"
}

Использование select для фильтрации данных

TanStack Query позволяет преобразовывать ответ перед попаданием в компоненты.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    select: (data) => ({
        id: data.id,
        name: data.name
    })
})

Это уменьшает объём данных, доступных UI.


Риски optimistic updates

Optimistic updates временно помещают данные в кеш до подтверждения сервера.

onMutate: async (newTodo) => {
    queryClient.setQueryData(['todos'], old => [
        ...old,
        newTodo
    ])
}

Если optimistic update содержит чувствительные данные, они:

  • попадают в память;
  • могут сохраниться при ошибке rollback;
  • отображаются в Devtools.

Безопасный rollback

onError: (error, variables, context) => {
    queryClient.setQueryData(
        ['todos'],
        context.previousTodos
    )
}

Rollback предотвращает сохранение некорректных данных.


Очистка чувствительных mutation results

Mutation cache тоже хранит данные.

После завершения операции желательно удалять приватные результаты.

mutation.reset()

Либо:

queryClient.getMutationCache().clear()

Использование SessionStorage вместо LocalStorage

Если persistence необходим временно, SessionStorage безопаснее.

Преимущества:

  • очищается после закрытия вкладки;
  • меньше время жизни;
  • снижает риск долгосрочной утечки.

Но XSS-риски всё равно сохраняются.


CSP как защита кеша

Content Security Policy снижает вероятность XSS.

Пример:

Content-Security-Policy:
default-src 'self';
script-src 'self';

Даже идеально настроенный TanStack Query не защищает от XSS без CSP.


Trusted Types

Trusted Types предотвращают опасные DOM-инъекции.

Особенно полезны в React-приложениях с:

  • пользовательским HTML;
  • markdown;
  • rich text editors.

Разделение публичного и приватного QueryClient

В крупных системах иногда используются отдельные QueryClient-инстансы.

Пример:

const publicClient = new QueryClient()

const privateClient = new QueryClient({
    defaultOptions: {
        queries: {
            gcTime: 0
        }
    }
})

Так можно:

  • отдельно управлять временем жизни;
  • изолировать persistence;
  • разграничивать политику безопасности.

Предотвращение race conditions

Во время logout могут завершиться старые запросы.

Опасный сценарий:

  1. Пользователь выходит.
  2. Кеш очищается.
  3. Старый запрос завершается.
  4. Данные снова попадают в кеш.

Отмена активных запросов

Безопасный logout:

const logout = async () => {
    await queryClient.cancelQueries()

    queryClient.clear()

    await api.logout()
}

AbortController и безопасность

TanStack Query поддерживает отмену запросов.

useQuery({
    queryKey: ['profile'],
    queryFn: async ({ signal }) => {
        const response = await fetch('/api/profile', {
            signal
        })

        return response.json()
    }
})

Это предотвращает попадание устаревших данных в кеш.


Проверка авторизации внутри queryFn

Даже при наличии кеша сервер обязан проверять авторизацию.

Нельзя полагаться на frontend cache.

Плохо:

if (cachedUser.isAdmin) {
    return sensitiveData
}

Безопасно:

const response = await fetch('/api/admin')

Сервер самостоятельно проверяет права доступа.


Zero Trust подход к кешу

Безопасная архитектура строится на предположении, что:

  • браузер может быть скомпрометирован;
  • JavaScript может быть прочитан;
  • LocalStorage небезопасен;
  • память вкладки не защищена;
  • Devtools доступны пользователю.

Поэтому кеш должен содержать минимум чувствительной информации.


Архитектура безопасного кеширования

Надёжная схема обычно выглядит так:

  1. Access token хранится в HttpOnly cookie.
  2. TanStack Query хранит только API-данные.
  3. Persistence отключён для приватных query.
  4. Кеш очищается при logout.
  5. Query keys включают userId.
  6. Sensitive queries имеют короткий gcTime.
  7. SSR фильтрует приватные данные.
  8. Devtools отключены в production.
  9. Сервер всегда выполняет повторную авторизацию.
  10. CSP защищает от XSS.