Files
certremind/tests/apiSecurity.test.js

638 lines
20 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/server/app.js';
import { pool, query } from '../src/server/db/pool.js';
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
vi.mock('../src/server/db/pool.js', () => ({
pool: {
connect: vi.fn(),
},
query: vi.fn(),
}));
vi.mock('../src/server/modules/monitoring/certificate.js', () => ({
getCertificateExpiry: vi.fn(),
}));
const USER_ID = '11111111-1111-4111-8111-111111111111';
const SITE_ID = '22222222-2222-4222-8222-222222222222';
const ALERT_ID = '33333333-3333-4333-8333-333333333333';
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
const PUSH_ENDPOINT = 'https://push.example.com/subscription';
function authCookie() {
return 'certremind_session=session-1';
}
function csrfCookie() {
return `${authCookie()}; certremind_csrf=csrf-token`;
}
function mockAuthenticatedUser() {
query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sessions s')) {
expect(params).toEqual(['session-1']);
return {
rows: [
{
user_id: USER_ID,
username: 'alice',
display_name: 'Alice',
},
],
};
}
throw new Error(`Unexpected query: ${sql}`);
});
}
describe('API security boundaries', () => {
beforeEach(() => {
query.mockReset();
pool.connect.mockReset();
getCertificateExpiry.mockReset();
});
it('requires a CSRF token for state-changing API requests', async () => {
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
error: 'CSRF トークンが不正です',
});
expect(query).not.toHaveBeenCalled();
});
it('requires authentication for site listing', async () => {
const app = createApp();
const response = await app.request('/api/sites');
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({
error: '認証が必要です',
});
expect(query).not.toHaveBeenCalled();
});
it('uses the session user when listing sites', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM sites');
expect(params).toEqual([USER_ID]);
return {
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: 'C = US, O = Example CA, CN = Example Root',
certificate_issued_at: '2026-01-01T00:00:00.000Z',
certificate_expires_at: '2026-12-31T00:00:00.000Z',
certificate_checked_at: '2026-05-21T00:00:00.000Z',
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
});
const app = createApp();
const response = await app.request('/api/sites', {
headers: {
Cookie: authCookie(),
},
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
sites: [
{
siteId: SITE_ID,
certificateIssuer: 'C = US, O = Example CA, CN = Example Root',
certificateIssuedAt: '2026-01-01T00:00:00.000Z',
certificateExpiresAt: '2026-12-31T00:00:00.000Z',
},
],
});
});
it('rejects private webhook URLs before insertion', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ alias: 'Internal', url: 'https://127.0.0.1/hook' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('does not expose registered push subscription lists', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(sql).toContain("notification_type = 'webhook'");
expect(params).toEqual([USER_ID]);
return {
rows: [
{
notification_method_id: WEBHOOK_ID,
alias: 'Deploy hook',
url: 'https://hooks.example.com/',
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
}).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM user_notification_settings');
expect(params).toEqual([USER_ID]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods', {
headers: {
Cookie: authCookie(),
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.webhooks).toHaveLength(1);
expect(data).not.toHaveProperty('pushSubscriptions');
expect(data.webhookMessageSettings).toMatchObject({
timezone: 'Asia/Tokyo',
usesDefault: true,
});
});
it('requires a CSRF token when saving webhook message settings', async () => {
const app = createApp();
const response = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
webhookMessageTemplate: '{{siteDomain}}',
}),
});
expect(response.status).toBe(403);
expect(query).not.toHaveBeenCalled();
});
it('rejects invalid webhook message templates before persistence', async () => {
mockAuthenticatedUser();
const app = createApp();
const unsupportedVariable = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
webhookMessageTemplate: '{{unknownValue}}',
}),
});
expect(unsupportedVariable.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('rejects invalid account timezone before persistence', async () => {
mockAuthenticatedUser();
const app = createApp();
const invalidTimezone = await app.request('/api/account/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
displayName: 'Alice',
timezone: 'Mars/Base',
}),
});
expect(invalidTimezone.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
expect(pool.connect).not.toHaveBeenCalled();
});
it('saves account timezone for the session user only', async () => {
const client = {
query: vi.fn(async (sql, params) => {
if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] };
expect(sql).toContain('INSERT INTO user_notification_settings');
expect(params).toEqual([USER_ID, 'Alice', 'America/New_York']);
return {
rows: [
{
user_id: USER_ID,
username: 'alice',
display_name: 'Alice',
otp_secret: null,
timezone: 'America/New_York',
},
],
};
}),
release: vi.fn(),
};
pool.connect.mockResolvedValue(client);
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/account/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
displayName: 'Alice',
timezone: 'America/New_York',
}),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
account: { timezone: 'America/New_York' },
});
expect(client.query).toHaveBeenCalledWith('COMMIT');
expect(client.release).toHaveBeenCalledOnce();
});
it('saves and deletes webhook message settings for the session user only', async () => {
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('INSERT INTO user_notification_settings');
expect(params).toEqual([USER_ID, '{{siteDomain}}']);
return {
rows: [
{
webhook_message_template: '{{siteDomain}}',
timezone: 'Asia/Tokyo',
},
],
};
});
const app = createApp();
const saveResponse = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
webhookMessageTemplate: '{{siteDomain}}',
}),
});
expect(saveResponse.status).toBe(200);
query.mockReset();
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('UPDATE user_notification_settings');
expect(sql).toContain('webhook_message_template = NULL');
expect(params).toEqual([USER_ID]);
return { rows: [{ webhook_message_template: null, timezone: 'Asia/Tokyo' }] };
});
const deleteResponse = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'DELETE',
headers: {
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
});
expect(deleteResponse.status).toBe(200);
});
it('reports the current browser push subscription as registered for the session user', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(sql).toContain("notification_type = 'push'");
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ registered: true });
});
it('reports push subscriptions for other users or unknown endpoints as unregistered', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ registered: false });
});
it('rejects non-HTTPS push subscription status endpoints', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('requires a CSRF token when deleting push subscriptions', async () => {
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(403);
expect(query).not.toHaveBeenCalled();
});
it('deletes the current user push subscription by endpoint', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('DELETE FROM notification_methods');
expect(sql).toContain("notification_type = 'push'");
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
});
it('does not delete push subscriptions owned by other users or unknown endpoints', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('DELETE FROM notification_methods');
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(404);
});
it('rejects non-HTTPS push subscription delete endpoints', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('stores the initial certificate metadata when creating a site', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date('2026-12-31T00:00:00.000Z');
getCertificateExpiry.mockResolvedValue({ issuer, issuedAt, expiresAt, hoursUntilExpiry: 24 * 30 });
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('INSERT INTO sites');
expect(sql).toContain('certificate_issuer');
expect(sql).toContain('certificate_issued_at');
expect(sql).toContain('certificate_expires_at');
expect(sql).toContain('certificate_checked_at');
expect(params).toEqual([USER_ID, 'https://example.com/', 'Example', issuer, issuedAt, expiresAt]);
return {
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: issuer,
certificate_issued_at: issuedAt.toISOString(),
certificate_expires_at: expiresAt.toISOString(),
certificate_checked_at: '2026-05-21T00:00:00.000Z',
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
});
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(201);
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/', {
timeoutMs: 3000,
});
await expect(response.json()).resolves.toMatchObject({
site: {
siteId: SITE_ID,
certificateIssuer: issuer,
certificateIssuedAt: issuedAt.toISOString(),
certificateExpiresAt: expiresAt.toISOString(),
},
});
});
it('rejects site creation when the initial certificate expiry cannot be fetched', async () => {
getCertificateExpiry.mockRejectedValue(new Error('OpenSSL の実行がタイムアウトしました'));
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
await expect(response.json()).resolves.toMatchObject({
error: '証明書の期限を取得できませんでした',
details: {
reason: 'OpenSSL の実行がタイムアウトしました',
},
});
});
it('does not mark another user alert as read', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('UPDATE alert_history');
expect(params).toEqual([USER_ID, ALERT_ID]);
return { rows: [] };
});
const app = createApp();
const response = await app.request(`/api/alerts/${ALERT_ID}/read`, {
method: 'PATCH',
headers: {
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
});
expect(response.status).toBe(404);
});
it('rejects settings that reference another user webhook', async () => {
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async () => ({
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: null,
certificate_issued_at: null,
certificate_expires_at: null,
certificate_checked_at: null,
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
}))
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(params).toEqual([USER_ID, [WEBHOOK_ID]]);
return { rowCount: 0, rows: [] };
});
const app = createApp();
const response = await app.request(`/api/sites/${SITE_ID}/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
conditions: [{ thresholdHours: 24 }],
webhookMethodIds: [WEBHOOK_ID],
pushEnabled: false,
}),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: '選択された Webhook が見つかりません',
});
});
});