- 😕 Что такое политика безопасности содержимого?
- 🔬 На чем мы сосредоточимся?
- ⚙️ Конфигурация
- Альтернативная конфигурация
- 🎬 Первая попытка
- 📜 Скрипт заголовка
- 💯 Политика безопасности содержимого SvelteKit: Тестирование
- 🙌🏽 Политика безопасности содержимого SvelteKit: Подведение итогов
- 🙏🏽 SvelteKit Content Security Policy: Обратная связь
😕 Что такое политика безопасности содержимого?
Сегодня мы рассмотрим политику безопасности содержимого SvelteKit. Политика безопасности содержимого — это набор метаданных, которые вы можете отправлять с вашего сервера в браузеры посетителей для повышения безопасности. Она разработана для уменьшения площади атаки межсайтового скриптинга (XSS). По своей сути, директивы сценариев помогают браузеру идентифицировать посторонние сценарии, которые могли быть внедрены злоумышленниками. Однако политика безопасности контента охватывает стили, изображения и другие ресурсы, помимо скриптов.
В случае со скриптами мы видим, что можно вычислить криптографический хэш предполагаемого скрипта на сервере и отправить его вместе со страницей. Хешируя полученный скрипт и сравнивая его со списком хешей CSP, браузер может потенциально обнаружить внедренные вредоносные скрипты. Мы увидим, что хэширование — не единственный вариант, и рассмотрим, когда можно использовать альтернативы. В феврале SvelteKit получил обновление, которое позволяет автоматически вычислять хэши сценариев и вставлять тег политики безопасности контента в головку страницы.
Обновлять политику безопасности контента следует только в том случае, если вы уверены, что знаете, что делаете. Можно полностью остановить рендеринг сайта с неправильной политикой.
🔬 На чем мы сосредоточимся?
Мы рассмотрим, почему и как можно использовать генерируемый SvelteKit метатег CSP для добавления заголовка HTTP Content Security Policy к статическому сайту. Кроме того, мы также рассмотрим конфигурацию для развертывания сайта с заголовками на Netlify и Cloudflare Pages. Мы будем использовать стартовый блог SvelteKit MDsveX, хотя этот подход должен хорошо работать и с другими сайтами. Все это должно обеспечить нам рейтинг A на SecurityHeaders.com для сайта.
⚙️ Конфигурация
Если вы хотите написать код, то клонируйте SvelteKit MDsveX blog starter и установите пакеты:
git clone https://github.com/rodneylab/sveltekit-blog-mdx.git sveltekit-content-security-policy
cd sveltekit-content-security-policy
pnpm install
pnpm run dev
Нам просто нужно обновить svelte.config.js
, чтобы он создал для нас мета CSP:
/** @type {import('@sveltejs/kit').Config} */
import adapter from "@sveltejs/adapter-static";
import { imagetools } from "vite-imagetools";
import { mdsvex } from "mdsvex";
import preprocess from "svelte-preprocess";
const config = {
extensions: [".svelte", ".md", ".svelte.md"],
preprocess: [
mdsvex({ extensions: [".svelte.md", ".md", ".svx"] }),
preprocess({
scss: {
prependData: "@import 'src/lib/styles/variables.scss';",
},
}),
],
kit: {
adapter: adapter({ precompress: true }),
csp: {
mode: "hash",
directives: { "script-src": ["self"] },
},
files: {
hooks: "src/hooks",
},
prerender: { default: true },
vite: {
define: {
"process.env.VITE_BUILD_TIME": JSON.stringify(new Date().toISOString()),
},
plugins: [imagetools({ force: true })],
},
},
};
export default config;
Мы можем установить режим hash
, nonce
или auto
. hash
будет вычислять криптографический хэш SHA256 всех скриптов, которые SvelteKit генерирует при создании сайта. Эти скрипты впоследствии используются браузерами посетителей для выявления нечестной игры. Хеши являются хорошим выбором для статических сайтов. Это связано с тем, что скрипты фиксируются при создании и не меняются до тех пор, пока вы не перестроите сайт.
На сайтах SSR SvelteKit может генерировать разные скрипты для каждого запроса. Чтобы избежать дополнительных накладных расходов на вычисление набора хэшей для каждого запроса, альтернативой может быть использование nonce. Nonce — это просто случайно сгенерированная строка. Мы просто добавляем nonce в каждый тег скрипта, а также включаем его в CSP meta. Теперь браузер просто проверяет соответствие nonce в скрипте и в метатеге. Чтобы это работало лучше всего, нам нужно генерировать новый случайный nonce при каждом запросе.
Третий вариант, auto
, просто выбирает hash
для пререндеренного содержимого и nonce
для всего остального.
Альтернативная конфигурация
Эта конфигурация (выше) является немного базовой. Возможно, вы захотите немного расширить конфигурацию. В этом случае имеет смысл извлечь конфигурацию в отдельный файл. В этом случае вы можете обновить svelte.config.js
следующим образом:
/** @type {import('@sveltejs/kit').Config} */
import adapter from '@sveltejs/adapter-static';
import { imagetools } from 'vite-imagetools';
import { mdsvex } from 'mdsvex';
import preprocess from 'svelte-preprocess';
import cspDirectives from './csp-directives.mjs';
const config = {
extensions: ['.svelte', '.md', '.svelte.md'],
preprocess: [
mdsvex({ extensions: ['.svelte.md', '.md', '.svx'] }),
preprocess({
scss: {
prependData: "@import 'src/lib/styles/variables.scss';",
},
}),
],
kit: {
adapter: adapter({ precompress: true }),
csp: {
mode: 'hash',
directives: cspDirectives,
},
files: {
hooks: 'src/hooks',
},
prerender: { default: true },
vite: {
define: {
'process.env.VITE_BUILD_TIME': JSON.stringify(new Date().toISOString()),
},
plugins: [imagetools({ force: true })],
},
},
};
export default config;
Вот один из возможных наборов значений, которые вы можете использовать. Конечно, он не будет соответствовать вашему случаю использования, и вы должны определить набор значений, которые вам подходят.
const rootDomain = process.env.VITE_DOMAIN; // or your server IP for dev
const cspDirectives = {
'base-uri': ["'self'"],
'child-src': ["'self'"],
'connect-src': ["'self'", 'ws://localhost:*'],
// 'connect-src': ["'self'", 'ws://localhost:*', 'https://hcaptcha.com', 'https://*.hcaptcha.com'],
'img-src': ["'self'", 'data:'],
'font-src': ["'self'", 'data:'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
'frame-src': [
"'self'",
// "https://*.stripe.com",
// "https://*.facebook.com",
// "https://*.facebook.net",
// 'https://hcaptcha.com',
// 'https://*.hcaptcha.com',
],
'manifest-src': ["'self'"],
'media-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'style-src': ["'self'", "'unsafe-inline'"],
// 'style-src': ["'self'", "'unsafe-inline'", 'https://hcaptcha.com', 'https://*.hcaptcha.com'],
'default-src': [
'self',
...(rootDomain ? [rootDomain, `ws://${rootDomain}`] : []),
// 'https://*.google.com',
// 'https://*.googleapis.com',
// 'https://*.firebase.com',
// 'https://*.gstatic.com',
// 'https://*.cloudfunctions.net',
// 'https://*.algolia.net',
// 'https://*.facebook.com',
// 'https://*.facebook.net',
// 'https://*.stripe.com',
// 'https://*.sentry.io',
],
'script-src': [
'self',
// 'https://*.stripe.com',
// 'https://*.facebook.com',
// 'https://*.facebook.net',
// 'https://hcaptcha.com',
// 'https://*.hcaptcha.com',
// 'https://*.sentry.io',
// 'https://polyfill.io',
],
'worker-src': ["'self'"],
// remove report-to & report-uri if you do not want to use Sentry reporting
'report-to': ["'csp-endpoint'"],
'report-uri': [
`https://sentry.io/api/${process.env.VITE_SENTRY_PROJECT_ID}/security/?sentry_key=${process.env.VITE_SENTRY_KEY}`,
],
};
export default cspDirectives;
🎬 Первая попытка
Вам нужно будет создать сайт, чтобы увидеть его волшебную работу:
pnpm build
pnpm preview
Теперь, если вы откроете инспектор в инструментах разработки браузера, вы сможете найти метатег, который включает политику безопасности контента.
Все это хорошо, но когда я развернул Netlify и провел тест с использованием сайта securityheaders.com. я не получил ничего для CSP. По этой причине я попробовал альтернативный подход. Альтернативой включению CSP в метатеги является использование HTTP-заголовков. Оба варианта допустимы, хотя HTTP-заголовки в большинстве случаев являются более эффективным подходом. Кроме того, используя HTTP-заголовки, вы можете добавить отчетность, используя сервис типа Sentry. Это позволит вам предупредить, если пользователи начнут получать ошибки CSP в своем браузере.
📜 Скрипт заголовка
Netlify, как и Cloudflare Pages, позволяет указать HTTP-заголовки для вашего статического сайта путем включения файла _headers
в папку static
. Хосты анализируют этот файл перед развертыванием и затем удаляют его (поэтому он не будет обслуживаться). Моя идея заключалась в том, чтобы написать скрипт для узла, который мы могли бы запускать после создания сайта. Этот скрипт будет просматривать папку build
на предмет HTML-файлов, а затем извлекать метатег content security и добавлять его в запись _headers
для страницы.
Вот сценарий узла, который я написал. Если вы хотите попробовать аналогичный подход, надеюсь, для вас не составит большого труда подстроить его под свой случай использования.
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { parse } from 'node-html-parser';
const __dirname = path.resolve();
const buildDir = path.join(__dirname, 'build');
const { VITE_SENTRY_ORG_ID, VITE_SENTRY_KEY, VITE_SENTRY_PROJECT_ID } = process.env;
function removeCspMeta(inputFile) {
const fileContents = fs.readFileSync(inputFile, { encoding: 'utf-8' });
const root = parse(fileContents);
const element = root.querySelector('head meta[http-equiv="content-security-policy"]');
const content = element.getAttribute('content');
root.remove(element);
return content;
}
const cspMap = new Map();
function findCspMeta(startPath, filter = /.html$/) {
if (!fs.existsSync(startPath)) {
console.error(`Unable to find CSP start path: ${startPath}`);
return;
}
const files = fs.readdirSync(startPath);
files.forEach((item) => {
const filename = path.join(startPath, item);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
findCspMeta(filename, filter);
} else if (filter.test(filename)) {
cspMap.set(
filename
.replace(buildDir, '')
.replace(/.html$/, '')
.replace(/^/index$/, '/'),
removeCspMeta(filename),
);
}
});
}
function createHeaders() {
const headers = `/*
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), document-domain=(), encrypted-media=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Report-To: {"group": "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://o${VITE_SENTRY_KEY}.ingest.sentry.io/api/${VITE_SENTRY_ORG_ID}/security/?sentry_key=${VITE_SENTRY_PROJECT_ID}"}]}
`;
const cspArray = [];
cspMap.forEach((csp, pagePath) =>
cspArray.push(`${pagePath}n Content-Security-Policy: ${csp}`),
);
const headersFile = path.join(buildDir, '_headers');
fs.writeFileSync(headersFile, `${headers}${cspArray.join('n')}`);
}
async function main() {
findCspMeta(buildDir);
createHeaders();
}
main();
В строках 47
—53
вы увидите, что я добавил некоторые другие HTTP-заголовки, которые securityheaders.com ищет. Функция findCspMeta
, начинающаяся в строке 22
— это то, что делает тяжелую работу по поиску meta в генерируемом SvelteKit выводе. Мы также используем пакет node-html-parser
для эффективного разбора DOM. В строках 34
—40
мы добавляем содержимое CSP в карту с путем страницы в качестве ключа. Позже мы используем эту карту для создания файла /build/_headers
. Мы записываем _headers
непосредственно в build, вместо static
, поскольку мы запускаем этот скрипт после сборки SvelteKit.
Вот пример вывода скрипта:
/*
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), document-domain=(), encrypted-media=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Report-To: {"group": "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://XXX.ingest.sentry.io/api/XXX/security/?sentry_key=XXX"}]}
/best-medium-format-camera-for-starting-out
Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-KD6K876QaEoRcbVCglIUUkrVfvbkkiOzn+MUAYvIE3I=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/contact
Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-t7R4W+8Ou9kpe3an17uRnyxB95SfUTIMJ/K2z6vu0Io=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/folding-camera
Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-4xx4DsEsRBOVYIl2xwCtDOZ+mGnU01sxNiKHZH57Z6w=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/
Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-mXijveCfKQlG2poJkRRzcdCDdFOlpwhP7utTdY0mOtU=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
/twin-lens-reflex-camera
Content-Security-Policy: child-src 'self'; default-src 'self'; frame-src 'self'; worker-src 'self'; connect-src 'self' ws://localhost:*; font-src 'self' data:; img-src 'self' data:; manifest-src 'self'; media-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-w5p2NquSvorJBfJewyjpg4Lm1Mzs7rALuFMPfF7I/OI=' 'sha256-zArBwCFLmTaX5PiopOgysXsLgzWtw+D2DfdI+gej1y0='; style-src 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'; report-to 'csp-endpoint'
Чтобы запустить скрипт, мы просто обновляем скрипт сборки package.json
:
{
"name": "sveltekit-blog-mdx",
"version": "2.0.0",
"scripts": {
"dev": "svelte-kit dev --port 3030",
"build": "npm run generate:manifest && svelte-kit build && npm run generate:headers",
"preview": "svelte-kit preview --port 3030",
"check": "svelte-check --fail-on-hints",
"check:watch": "svelte-check --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"lint:scss": "stylelint "src/**/*.{css,scss,svelte}"",
"format": "prettier --write --plugin-search-dir=. .",
"generate:headers": "node ./generate-headers.js",
"generate:images": "node ./generate-responsive-image-data.js",
"generate:manifest": "node ./generate-manifest.js",
"generate:sitemap": "node ./generate-sitemap.js",
"prettier:check": "prettier --check --plugin-search-dir=. .",
"prepare": "husky install"
},
💯 Политика безопасности содержимого SvelteKit: Тестирование
Перераспределение на Netlify и тестирование с помощью securityheaders.com еще раз, теперь все выглядит лучше.
Однако вы можете заметить, что оценка ограничена на уровне A (A+ — наивысшая оценка). Это связано с тем, что пока нам необходимо включить директиву unsafe-inline
для стилей (см. строку 23
в csp-directives.mjs
).
Это ограничение упоминается в запросе на поставку SvelteKit CSP. В примечании говорится, что это не понадобится, когда Svelte Kit перейдет на использование Web Animations API.
🙌🏽 Политика безопасности содержимого SvelteKit: Подведение итогов
В этом посте мы рассмотрели новую функцию SvelteKit Content Security Policy. В частности, мы затронули следующие вопросы:
- почему вы можете использовать хэши CSP вместо несов,
- способ извлечения сгенерированных SvelteKit метаданных CSP для каждой страницы,
- как вы можете передавать HTTP-заголовки безопасности CSP на вашем статическом сайте SvelteKit,
Дайте мне знать, если у вас есть другие или более чистые способы достижения тех же результатов. Вы можете оставить комментарий ниже или связаться со мной в Element, а также в Twitter @mention.
Полный код этого поста о политике безопасности контента SvelteKit вы можете посмотреть в репозитории Rodney Lab Git Hub.
🙏🏽 SvelteKit Content Security Policy: Обратная связь
Если вы нашли это видео полезным, смотрите ссылки ниже для дальнейшего связанного контента на этом сайте. Я надеюсь, что вы узнали что-то новое из этого видео. Дайте мне знать, есть ли способы улучшить его. Я надеюсь, что вы будете использовать код или стартер в своих собственных проектах. Не забудьте поделиться своей работой в Twitter, упомянув меня, чтобы я мог увидеть, что у вас получилось. И наконец, не забудьте сообщить мне о своих идеях относительно других коротких видео, которые вы хотели бы увидеть. О том, как связаться со мной, читайте ниже. Если вы нашли этот пост полезным, даже если вы можете позволить себе лишь крошечный вклад, пожалуйста, поддержите меня через Buy me a Coffee.
Наконец, не стесняйтесь поделиться этим постом в своих социальных сетях для всех ваших подписчиков, которые найдут его полезным. Помимо того, что вы можете оставить комментарий ниже, вы можете связаться со мной через @askRodney в Twitter, а также askRodney в Telegram. Также смотрите другие способы связаться с Rodney Lab. Я регулярно пишу о SvelteKit, а также о поисковой оптимизации и других темах. Также подпишитесь на рассылку новостей, чтобы быть в курсе наших последних проектов.