Пошук уроків, статей та іншого контенту
Зберемо комплексну конфігурацію Node.js API: HTTPS, логування, обробку помилок, права процесу та безпечні значення за замовчуванням.
Конфігурація API — це не лише номер порту. Вона визначає:
яким протоколом приймаються з’єднання;
які дані потрапляють у логи;
яку інформацію бачить клієнт під час помилок;
від імені якого користувача працює процес;
що станеться після критичної помилки;
які значення використовуються, якщо змінні середовища не задані.
Безпечний підхід має кілька правил:
Секрети не зберігаються в коді.
Небезпечні значення за замовчуванням не використовуються.
Помилки логуються детально на сервері, але клієнту повертається мінімальна інформація.
Після невідновлюваної помилки процес завершується, а не продовжує працювати у невідомому стані.
API не запускається від імені root, якщо для цього немає чіткої причини.
Для API у production потрібно використовувати HTTPS. Шифрування захищає:
токени автентифікації;
cookie;
дані запитів;
відповіді API;
службову інформацію між клієнтом і сервером.
httpsconst https = require('node:https');
const fs = require('node:fs');
const tlsOptions = {
key: fs.readFileSync(process.env.TLS_KEY_FILE),
cert: fs.readFileSync(process.env.TLS_CERT_FILE),
minVersion: 'TLSv1.2'
};
const server = https.createServer(tlsOptions, requestHandler);Приватний ключ не можна:
комітувати до репозиторію;
передавати через аргументи командного рядка;
виводити в логи;
зберігати у файлі, доступному всім користувачам системи.
Зазвичай шлях до ключа та сертифіката передають через змінні середовища:
TLS_KEY_FILE=/etc/my-api/tls/server.key
TLS_CERT_FILE=/etc/my-api/tls/server.crtФайл приватного ключа повинен бути доступний лише користувачу, від імені якого працює сервіс. Наприклад, у Linux доречні права 0600.
Для локального тестування можна створити тимчасовий самопідписаний сертифікат:
mkdir -p certs
openssl req -x509 -newkey rsa:2048 \
-keyout certs/server.key \
-out certs/server.crt \
-days 1 \
-nodes \
-subj "/CN=localhost"Такий сертифікат підходить лише для локальної розробки. Браузер і клієнти не вважатимуть його довіреним.
Змінні середовища потрібно не просто читати, а й перевіряти. Значення з process.env завжди є рядками, тому порт, таймаути та інші числові параметри слід явно перетворювати.
function positiveInteger(value, fallback) {
if (value === undefined || value === '') {
return fallback;
}
const number = Number(value);
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`Некоректне додатне ціле число: ${value}`);
}
return number;
}
const config = {
// Локальне прослуховування є безпечнішим значенням за замовчуванням.
host: process.env.HOST || '127.0.0.1',
port: positiveInteger(process.env.PORT, 8443),
requestTimeout: positiveInteger(process.env.REQUEST_TIMEOUT_MS, 30_000),
tlsKeyFile: process.env.TLS_KEY_FILE || './certs/server.key',
tlsCertFile: process.env.TLS_CERT_FILE || './certs/server.crt',
environment: process.env.NODE_ENV || 'development'
};127.0.0.1 означає, що сервер доступний лише локально. Це корисний default для розробки та для API, яке працює за reverse proxy.
Якщо API має бути доступним з інших машин, адресу можна явно задати:
HOST=0.0.0.0Таке значення не варто робити default без потреби: воно відкриває порт на всіх мережевих інтерфейсах.
Логи повинні бути придатними для машинної обробки. Формат JSON зручний для систем збору логів:
{"level":"info","event":"request.completed","requestId":"...","statusCode":200}Кожен запис повинен містити хоча б:
рівень (info, warn, error);
назву події;
час;
ідентифікатор запиту;
важливі безпечні метадані.
Не слід записувати в логи:
Authorization;
cookie;
паролі;
приватні ключі;
повне тіло запиту без необхідності;
токени та персональні дані.
Під час логування помилки можна записати stack trace на сервері, але не повертати його клієнту.
Помилки API можна поділити на два типи:
очікувані помилки клієнта: неправильний маршрут, некоректний метод, недійсні дані;
неочікувані помилки сервера: помилки файлової системи, збої залежностей, програмні помилки.
Для клієнта достатньо стабільного формату:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Внутрішня помилка сервера",
"requestId": "..."
}
}requestId допомагає знайти відповідний запис у логах.
Не варто повертати клієнту error.message для кожної внутрішньої помилки. Повідомлення може містити шлях до файлу, SQL-запит, назву внутрішнього сервісу або інші деталі системи.
Події uncaughtException та unhandledRejection означають, що процес опинився у невизначеному стані. Після запису помилки процес слід завершити. Перезапуск має виконувати менеджер процесів або оркестратор.
Нижче наведено мінімальний HTTPS API без зовнішніх бібліотек. Він демонструє:
HTTPS із мінімальною версією TLS;
перевірку конфігурації;
структуровані логи;
ідентифікатор запиту;
безпечні відповіді про помилки;
таймаути сервера;
graceful shutdown;
зниження привілеїв процесу.
'use strict';
const fs = require('node:fs');
const https = require('node:https');
const crypto = require('node:crypto');
function positiveInteger(value, fallback) {
if (value === undefined || value === '') {
return fallback;
}
const number = Number(value);
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`Некоректне додатне ціле число: ${value}`);
}
return number;
}
const config = {
// Безпечне значення за замовчуванням: доступ лише з локальної машини.
host: process.env.HOST || '127.0.0.1',
port: positiveInteger(process.env.PORT, 8443),
requestTimeout: positiveInteger(process.env.REQUEST_TIMEOUT_MS, 30_000),
headersTimeout: positiveInteger(process.env.HEADERS_TIMEOUT_MS, 10_000),
keepAliveTimeout: positiveInteger(process.env.KEEP_ALIVE_TIMEOUT_MS, 5_000),
tlsKeyFile: process.env.TLS_KEY_FILE || './certs/server.key',
tlsCertFile: process.env.TLS_CERT_FILE || './certs/server.crt',
environment: process.env.NODE_ENV || 'development',
runAsUser: process.env.RUN_AS_USER || ''
};
function writeLog(level, event, metadata = {}) {
const entry = {
timestamp: new Date().toISOString(),
level,
event,
...metadata
};
process.stderr.write(`${JSON.stringify(entry)}\n`);
}
function sendJson(response, statusCode, payload, requestId) {
const body = JSON.stringify(payload);
response.statusCode = statusCode;
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.setHeader('Content-Length', Buffer.byteLength(body));
response.setHeader('X-Request-Id', requestId);
response.setHeader('X-Content-Type-Options', 'nosniff');
response.setHeader('Referrer-Policy', 'no-referrer');
if (config.environment === 'production') {
response.setHeader(
'Strict-Transport-Security',
'max-age=31536000'
);
}
response.end(body);
}
function sendError(response, statusCode, code, message, requestId) {
sendJson(
response,
statusCode,
{
error: {
code,
message,
requestId
}
},
requestId
);
}
function requestHandler(request, response) {
const requestId = crypto.randomUUID();
const startedAt = process.hrtime.bigint();
response.setHeader('X-Request-Id', requestId);
try {
const requestUrl = new URL(
request.url || '/',
`https://${request.headers.host || 'localhost'}`
);
if (requestUrl.pathname === '/healthz' && request.method === 'GET') {
sendJson(
response,
200,
{
status: 'ok'
},
requestId
);
return;
}
if (requestUrl.pathname === '/api/hello' && request.method === 'GET') {
sendJson(
response,
200,
{
message: 'Вітаємо в API'
},
requestId
);
return;
}
if (requestUrl.pathname === '/api/hello') {
sendError(
response,
405,
'METHOD_NOT_ALLOWED',
'Метод не підтримується',
requestId
);
return;
}
sendError(
response,
404,
'NOT_FOUND',
'Маршрут не знайдено',
requestId
);
} catch (error) {
writeLog('error', 'request.failed', {
requestId,
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
if (!response.headersSent) {
sendError(
response,
500,
'INTERNAL_ERROR',
'Внутрішня помилка сервера',
requestId
);
} else {
response.destroy();
}
} finally {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
writeLog('info', 'request.completed', {
requestId,
method: request.method,
path: request.url,
statusCode: response.statusCode,
durationMs: Math.round(durationMs),
remoteAddress: request.socket.remoteAddress
});
}
}
let server;
function dropPrivileges() {
if (!config.runAsUser) {
return;
}
if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
throw new Error(
'RUN_AS_USER можна використовувати лише під час запуску від root у Unix-подібній системі'
);
}
if (typeof process.setgid !== 'function' || typeof process.setuid !== 'function') {
throw new Error('Поточна платформа не підтримує зміну Unix-привілеїв');
}
// Спочатку змінюємо групу, потім користувача.
process.setgid(config.runAsUser);
process.setuid(config.runAsUser);
writeLog('info', 'process.privileges_dropped', {
user: config.runAsUser
});
}
function shutdown(signal) {
writeLog('info', 'process.shutdown_started', { signal });
server.close((error) => {
if (error) {
writeLog('error', 'process.shutdown_failed', {
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
process.exitCode = 1;
return;
}
writeLog('info', 'process.shutdown_completed');
process.exitCode = 0;
});
setTimeout(() => {
writeLog('error', 'process.shutdown_forced');
process.exit(1);
}, 10_000).unref();
}
process.on('uncaughtException', (error) => {
writeLog('fatal', 'process.uncaught_exception', {
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
writeLog('fatal', 'process.unhandled_rejection', {
reason: reason instanceof Error
? {
name: reason.name,
message: reason.message,
stack: reason.stack
}
: String(reason)
});
process.exit(1);
});
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
try {
const tlsOptions = {
key: fs.readFileSync(config.tlsKeyFile),
cert: fs.readFileSync(config.tlsCertFile),
minVersion: 'TLSv1.2'
};
server = https.createServer(tlsOptions, requestHandler);
server.requestTimeout = config.requestTimeout;
server.headersTimeout = config.headersTimeout;
server.keepAliveTimeout = config.keepAliveTimeout;
server.on('clientError', (error, socket) => {
writeLog('warn', 'http.client_error', {
error: {
name: error.name,
message: error.message
}
});
socket.destroy();
});
server.on('error', (error) => {
writeLog('fatal', 'server.error', {
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
process.exit(1);
});
server.listen(config.port, config.host, () => {
try {
// Для порту 8443 root не потрібен, тому зазвичай цей параметр не задають.
dropPrivileges();
writeLog('info', 'server.started', {
host: config.host,
port: config.port,
environment: config.environment
});
} catch (error) {
writeLog('fatal', 'process.privilege_change_failed', {
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
server.close(() => process.exit(1));
}
});
} catch (error) {
writeLog('fatal', 'server.start_failed', {
error: {
name: error.name,
message: error.message,
stack: error.stack
}
});
process.exit(1);
}Запуск локального прикладу:
NODE_ENV=development \
TLS_KEY_FILE=./certs/server.key \
TLS_CERT_FILE=./certs/server.crt \
node server.jsПеревірка endpoint:
curl --insecure https://127.0.0.1:8443/healthzПрапорець --insecure потрібен лише через самопідписаний локальний сертифікат. У production клієнт повинен перевіряти сертифікат у звичайному режимі.
Запуск процесу від імені root збільшує наслідки будь-якої вразливості. Якщо API буде скомпрометовано, зловмисник потенційно отримає права root.
Безпечніший варіант:
створити окремого системного користувача для API;
надати йому доступ лише до потрібних файлів;
запускати процес від імені цього користувача;
не надавати йому доступ до всього проєкту або системи.
Для портів нижче 1024 у Unix-подібних системах зазвичай потрібні підвищені привілеї. Один із підходів:
процес запускається з правами root;
відкриває порт;
змінює групу;
змінює користувача;
продовжує роботу без root-привілеїв.
У прикладі це вмикається параметром:
RUN_AS_USER=my-api-userТакий режим потребує, щоб користувач уже існував у системі. Якщо API слухає порт 8443, потреби запускати його від root немає.
Важливо, що після зміни привілеїв процес може втратити доступ до приватного ключа. Тому ключ потрібно прочитати до зниження привілеїв, як це зроблено в прикладі, або налаштувати права на файл так, щоб цільовий користувач міг його читати.
Під час SIGTERM процес не повинен одразу завершуватися. Спочатку він має:
припинити приймати нові з’єднання;
дати завершитися поточним запитам;
звільнити ресурси;
завершити процес у розумний проміжок часу.
Це особливо важливо під час оновлення контейнера або перезапуску сервісу. Таймер примусового завершення потрібен на випадок, якщо з’єднання зависло.
Без таймаутів клієнт може утримувати з’єднання надто довго. Це витрачає сокети, пам’ять і вільні ресурси процесу.
Для HTTPS-сервера важливі такі параметри:
requestTimeout — максимальний час обробки запиту;
headersTimeout — час очікування заголовків;
keepAliveTimeout — час очікування наступного запиту в keep-alive-з’єднанні.
Значення потрібно підбирати під API. Надто короткий таймаут може переривати нормальні запити, а надто довгий зменшує захист від повільних або завислих клієнтів.
Помилка конфігурації повинна виявлятися під час старту, а не після першого запиту. Прикладами таких помилок є:
відсутній TLS-сертифікат;
неправильний порт;
шлях до ключа вказує не на файл;
спроба використовувати неіснуючого системного користувача;
некоректний формат числового параметра.
Краще завершити процес із fatal-логом, ніж запустити API з непередбачуваною конфігурацією.
const apiKey = 'production-secret';Такий секрет може потрапити до Git, логів code review або резервних копій. Використовуйте змінні середовища або спеціалізоване сховище секретів.
response.end(JSON.stringify({
error: error.stack
}));Stack trace розкриває структуру застосунку та внутрішні деталі. Записуйте його в захищені серверні логи, а клієнту повертайте загальне повідомлення та requestId.
Якщо сервіс слухає порт 8443, root-привілеї не потрібні. Не використовуйте root лише для спрощення запуску.
Повний заголовок Authorization, cookie або тіло запиту можуть містити секрети. Логуйте лише необхідні поля та явно видаляйте конфіденційні значення.
0.0.0.0 як безумовного defaultЦе робить API доступним через усі інтерфейси. Використовуйте його лише тоді, коли зовнішній доступ дійсно потрібен і додатковий захист налаштований на рівні мережі або reverse proxy.
uncaughtExceptionПісля неперехопленої помилки стан процесу може бути пошкоджений. Надійніше завершити процес і дозволити системі керування сервісом запустити його знову.
Безпечна конфігурація Node.js API включає не одну опцію, а узгоджений набір рішень:
використовуйте HTTPS і не зберігайте приватний ключ у коді;
перевіряйте змінні середовища та задавайте безпечні defaults;
ведіть структуровані логи без секретів;
повертайте клієнту узагальнені помилки з ідентифікатором запиту;
завершуйте процес після критичних неперехоплених помилок;
запускайте сервіс із мінімальними правами;
використовуйте таймаути та graceful shutdown;
не відкривайте API назовні без явної потреби.