Пришел из React, приземлился в SvelteKit
Это самый новый инструмент в моем стеке и тот, который делает больше всего за раз. SvelteKit рендерит страницы на сервере, обрабатывает отправку форм и проксирует вызовы API к двум Go сервисам из одного Node процесса. Раньше я в основном работал с React и стеком MERN, поэтому идея того, что загрузка страницы и отправка формы живут в одном файле, с функцией load и объектом actions рядом друг с другом, требовала привыкания. Это не однопраничное приложение, вызывающее REST API из клиентского JavaScript. Большинство важных вещей здесь вообще не касаются собственного скрипт-движка браузера.

Браузер никогда не держит токен
Это решение архитектуры, которое я больше всего защищаю в этом проекте. Токен доступа, который доказывает кто ты, живет в httponly cookie. Это значит, что никакой JavaScript, работающий в браузере (ни мой, ни расширение браузера, ни XSS попытка если одна когда-нибудь просочится), не может его прочитать. Но он все еще должен попасть из этого cookie в заголовок Authorization, который ожидают Go сервисы, и это преобразование происходит полностью на сервере, внутри SvelteKit.
Каждый запрос на backend проходит через перехватывающий прокси-маршрут на фронтенд сервере. Браузер вызывает /api/whatever на собственном домене фронтенда, SvelteKit читает cookie на стороне сервера и прикрепляет его как настоящий bearer токен перед перенаправлением запроса дальше:
export async function forwardRequest(
event: RequestEvent,
target: URL,
extraHeaders: Record<string, string> = {}
): Promise<Response> {
const allowed = getAllowedOrigins();
if (!isAllowedTarget(target, allowed)) {
throw new Error(`target origin not in allowed origins: ${target.origin}`);
}
const headers = new Headers(extraHeaders);
const contentType = event.request.headers.get('content-type');
if (contentType) headers.set('content-type', contentType);
...
return fetch(target, init);
}Проверка isAllowedTarget для белого списка внутренних источников существует именно поэтому: баг где-то выше по потоку не может быть обманут отправкой запроса на какой-то произвольный хост с нашими заголовками авторизации, это в сущности защита от SSRF для чего-то, что с поверхности выглядит как обычная функция прокси.
Состояние сеанса без проверки, нарочно
Еще одно место, где токены мне на время была непонятна. hooks.server.ts работает на каждом запросе и решает кто залогинен для этого запроса, но только декодирует JWT, подпись вообще не проверяет.
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
let sessionFromAccessToken = false;
const accessToken = event.cookies.get('access_token');
if (accessToken) {
const claims = decodeAccessToken(accessToken);
if (claims && !isExpired(claims)) {
event.locals.user = claimsToLocalsUser(claims);
sessionFromAccessToken = true;
}
}
if (!sessionFromAccessToken) {
const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
const response = await event.fetch('/auth/refresh', { method: 'POST' });
...
}
}
...
};На первый взгляд это выглядит как дыра в безопасности, и это было бы, если бы locals.user когда-либо рассматривался как доказательство чего-то. Это не так. Он только решает что рендерить на странице: показывается ли ссылка админа в навигации, показывает форма «выход» или «вход». Каждая настоящая операция записи все еще проходит через Go сервисы, и именно они проверяют подпись против публичных ключей авторизации перед тем, как что-то делать. Если бы кто-то подделал cookie, утверждающий что они админ, UI с удовольствием показал бы им ссылку админа в навигации, а потом каждый запрос который они делали бы был отклонен реальной проверкой на другом конце. Косметика с этой стороны, несущая конструкция с той.
Действие формы, не вызов fetch
Вход это обычная HTML форма, отправленная как действие SvelteKit, что означает она работает даже с отключенным JavaScript:
export const actions: Actions = {
default: async ({ request, fetch, url }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '');
const password = String(form.get('password') ?? '');
let response: Response;
try {
response = await fetch('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password })
});
} catch (err) {
const apiError = await parseApiError(err);
return fail(502, { error: apiError.message, requestId: apiError.requestId, username });
}
if (!response.ok) {
const apiError = await parseApiError(response);
return fail(response.status, { error: apiError.message, requestId: apiError.requestId, username });
}
throw redirect(303, safeRedirectTarget(url.searchParams.get('redirectTo')));
}
};Этот вызов fetch это собственный серверный fetch SvelteKit, работающий внутри действия формы, попадающий на внутренний маршрут /auth/login который сам является прокси сверху. Браузер видит только обычный POST формы и редирект в конце.
В первый раз когда я это подключал я все время пытался вызвать сервис авторизации прямо из <script> в компоненте как я делал бы в React:
<script lang="ts">
// что я пытался первым, прямо из привычки React
async function login(username: string, password: string) {
const res = await fetch('http://localhost:8081/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
}
</script>Все время получал CORS, потому что это браузер вызывает совершенно другой источник напрямую, и в итоге принял что нет, здесь это не так работает, сервер делает это вместо этого.
Markdown который не может запускать скрипты
Содержимое постов и комментарии оба пишутся как markdown и рендерятся в HTML, что означает ввод пользователя в итоге становится реальным DOM на странице. Это учебник для хранимого XSS если ты не осторожен. Рендеринг проходит через marked и затем прямо через DOMPurify с явным белым списком, не черным:
const POST_ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'del', 'a', 'ul', 'ol', 'li',
'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span',
'img', 'video', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
];
const COMMENT_ALLOWED_TAGS = ['p', 'br', 'a', 'strong', 'em', 'code'];
const COMMENT_ALLOWED_ATTR = ['href'];Посты получают более широкий белый список чем комментарии, потому что только админы пишут посты, собственный API блога отклоняет кого-либо еще на endpoint create/update, поэтому я доверяю этому контенту больше. Комментарии приходят от анонимных незнакомцев в интернете, поэтому они получают самый маленький список, нет изображений, нет стилей, едва больше чем параграф и ссылка. Даже тег video получает дополнительную проверку сверх белого списка, хук который позволяет элементу <video> держать его src только если источник этого URL соответствует нашему собственному хосту медиа, поэтому комментарий не может указать тег video на совершенно другой домен.
Маленькая система, но это место где я чувствовал что я действительно понимаю настоящий принцип безопасности (белый список побеждает черный список, каждый раз) вместо того чтобы просто быть ему рассказанным на лекции и кивать в согласии.