refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
import {CSRF_COOKIE_NAME, CSRF_FORM_FIELD, CSRF_HEADER_NAME} from '@fluxer/constants/src/Cookies';
import type {Context, MiddlewareHandler} from 'hono';
import {getCookie, setCookie} from 'hono/cookie';
const TOKEN_LENGTH = 32;
const TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24;
export interface CreateCsrfProtectionOptions {
secretKeyBase: string;
secureCookie: boolean;
cookiePath?: string;
cookieSameSite?: 'Strict' | 'Lax' | 'None';
ignoredPathSuffixes?: Array<string>;
}
export interface CsrfProtection {
middleware: MiddlewareHandler;
getToken: (c: Context) => string;
verifySignedToken: (signedToken: string) => string | null;
}
export function createCsrfProtection(options: CreateCsrfProtectionOptions): CsrfProtection {
const secretKey = Buffer.from(options.secretKeyBase);
const cookiePath = options.cookiePath ?? '/';
const cookieSameSite = options.cookieSameSite ?? 'Strict';
const ignoredPathSuffixes = options.ignoredPathSuffixes ?? [];
function signToken(token: string): string {
const signature = createHmac('sha256', secretKey).update(token).digest('base64url');
return `${token}.${signature}`;
}
function verifySignedToken(signedToken: string): string | null {
const parts = signedToken.split('.');
if (parts.length !== 2) {
return null;
}
const [token, providedSignature] = parts;
if (!token || !providedSignature) {
return null;
}
const expectedSignature = createHmac('sha256', secretKey).update(token).digest('base64url');
try {
const providedBuffer = Buffer.from(providedSignature, 'base64url');
const expectedBuffer = Buffer.from(expectedSignature, 'base64url');
if (providedBuffer.length !== expectedBuffer.length) {
return null;
}
if (timingSafeEqual(providedBuffer, expectedBuffer)) {
return token;
}
} catch {
return null;
}
return null;
}
function getToken(c: Context): string {
const existingCookie = getCookie(c, CSRF_COOKIE_NAME);
if (existingCookie && verifySignedToken(existingCookie)) {
return existingCookie;
}
const token = randomBytes(TOKEN_LENGTH).toString('base64url');
const signedToken = signToken(token);
setCookie(c, CSRF_COOKIE_NAME, signedToken, {
httpOnly: true,
secure: options.secureCookie,
sameSite: cookieSameSite,
maxAge: TOKEN_MAX_AGE_SECONDS,
path: cookiePath,
});
return signedToken;
}
const middleware: MiddlewareHandler = async (c, next) => {
const method = c.req.method.toUpperCase();
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
await next();
return undefined;
}
const path = c.req.path;
if (ignoredPathSuffixes.some((suffix) => path.endsWith(suffix))) {
await next();
return undefined;
}
const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
if (!cookieToken) {
return c.text('CSRF token missing', 403);
}
const verifiedCookieToken = verifySignedToken(cookieToken);
if (!verifiedCookieToken) {
return c.text('CSRF token invalid', 403);
}
const submittedToken = await extractSubmittedToken(c);
if (!submittedToken) {
return c.text('CSRF token not provided', 403);
}
const verifiedSubmittedToken = verifySignedToken(submittedToken);
if (!verifiedSubmittedToken) {
return c.text('CSRF token invalid', 403);
}
if (verifiedCookieToken !== verifiedSubmittedToken) {
return c.text('CSRF token mismatch', 403);
}
await next();
return undefined;
};
return {
middleware,
getToken,
verifySignedToken,
};
}
async function extractSubmittedToken(c: Context): Promise<string | null> {
const headerToken = c.req.header(CSRF_HEADER_NAME);
if (headerToken) {
return headerToken;
}
const contentType = c.req.header('content-type') ?? '';
const isFormRequest =
contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data');
if (!isFormRequest) {
return null;
}
try {
const body = await c.req.parseBody();
const formToken = body[CSRF_FORM_FIELD];
return typeof formToken === 'string' ? formToken : null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {isIP} from 'node:net';
export interface ValidateOutboundEndpointOptions {
name: string;
allowHttp: boolean;
allowLocalhost: boolean;
allowPrivateIpLiterals: boolean;
}
export function validateOutboundEndpointUrl(rawEndpoint: string, options: ValidateOutboundEndpointOptions): URL {
let endpointUrl: URL;
try {
endpointUrl = new URL(rawEndpoint);
} catch {
throw new Error(`${options.name} must be a valid URL`);
}
if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
throw new Error(`${options.name} must use http or https`);
}
if (endpointUrl.protocol === 'http:' && !options.allowHttp) {
throw new Error(`${options.name} must use https`);
}
if (endpointUrl.username || endpointUrl.password) {
throw new Error(`${options.name} must not include URL credentials`);
}
if (endpointUrl.search || endpointUrl.hash) {
throw new Error(`${options.name} must not include query string or fragment`);
}
const hostname = endpointUrl.hostname.toLowerCase();
if (!hostname) {
throw new Error(`${options.name} must include a hostname`);
}
if (!options.allowLocalhost && isLocalhostHostname(hostname)) {
throw new Error(`${options.name} cannot use localhost`);
}
if (!options.allowPrivateIpLiterals && isPrivateOrSpecialIpLiteral(hostname)) {
throw new Error(`${options.name} cannot use private or special IP literals`);
}
return endpointUrl;
}
export function normalizeEndpointOrigin(endpointUrl: URL): string {
const pathname = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
const normalisedPath = pathname === '/' ? '' : pathname;
return `${endpointUrl.protocol}//${endpointUrl.host}${normalisedPath}`;
}
export function buildEndpointUrl(endpointUrl: URL, path: string): string {
const trimmedPath = path.trim();
if (!trimmedPath) {
return normalizeEndpointOrigin(endpointUrl);
}
const lowercasePath = trimmedPath.toLowerCase();
if (lowercasePath.startsWith('http://') || lowercasePath.startsWith('https://') || trimmedPath.startsWith('//')) {
throw new Error('Outbound path must be relative');
}
const prefixedPath = trimmedPath.startsWith('/') ? trimmedPath : `/${trimmedPath}`;
return `${normalizeEndpointOrigin(endpointUrl)}${prefixedPath}`;
}
function isLocalhostHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname.endsWith('.localhost');
}
function isPrivateOrSpecialIpLiteral(hostname: string): boolean {
const ipVersion = isIP(hostname);
if (!ipVersion) {
return false;
}
if (ipVersion === 4) {
return isPrivateOrSpecialIPv4(hostname);
}
return isPrivateOrSpecialIPv6(hostname);
}
function isPrivateOrSpecialIPv4(hostname: string): boolean {
const octets = hostname.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return true;
}
const [first, second] = octets;
if (first === 0 || first === 10 || first === 127) return true;
if (first === 169 && second === 254) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
if (first === 192 && second === 168) return true;
if (first === 100 && second >= 64 && second <= 127) return true;
if (first === 198 && (second === 18 || second === 19)) return true;
if (first >= 224) return true;
return false;
}
function isPrivateOrSpecialIPv6(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (lower === '::' || lower === '::1') {
return true;
}
if (lower.startsWith('::ffff:')) {
const mapped = lower.slice('::ffff:'.length);
return isPrivateOrSpecialIPv4(mapped);
}
if (lower.startsWith('fe80:')) return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
if (lower.startsWith('ff')) return true;
return false;
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {CSRF_HEADER_NAME} from '@fluxer/constants/src/Cookies';
import {createCsrfProtection} from '@fluxer/hono/src/security/CsrfProtection';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
describe('CsrfProtection', () => {
test('accepts form token for mutating request', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', async (c) => {
const body = await c.req.parseBody();
return c.text(typeof body['locale'] === 'string' ? body['locale'] : 'missing');
});
const formResponse = await app.request('/form');
expect(formResponse.status).toBe(200);
const setCookie = formResponse.headers.get('set-cookie');
expect(setCookie).toBeTruthy();
const cookieHeader = setCookie?.split(';')[0] ?? '';
const body = (await formResponse.json()) as {token: string};
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
cookie: cookieHeader,
},
body: `_csrf=${encodeURIComponent(body.token)}&locale=en-US`,
});
expect(submitResponse.status).toBe(200);
expect(await submitResponse.text()).toBe('en-US');
});
test('rejects mutating request without submitted token', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', (c) => c.text('ok'));
const formResponse = await app.request('/form');
const setCookie = formResponse.headers.get('set-cookie') ?? '';
const cookieHeader = setCookie.split(';')[0] ?? '';
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
cookie: cookieHeader,
},
body: JSON.stringify({x: 1}),
});
expect(submitResponse.status).toBe(403);
});
test('accepts header token for json requests', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', (c) => c.text('ok'));
const formResponse = await app.request('/form');
const setCookie = formResponse.headers.get('set-cookie') ?? '';
const cookieHeader = setCookie.split(';')[0] ?? '';
const body = (await formResponse.json()) as {token: string};
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: cookieHeader,
[CSRF_HEADER_NAME]: body.token,
},
body: JSON.stringify({x: 1}),
});
expect(submitResponse.status).toBe(200);
});
});

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {buildEndpointUrl, validateOutboundEndpointUrl} from '@fluxer/hono/src/security/OutboundEndpoint';
import {describe, expect, test} from 'vitest';
describe('OutboundEndpoint', () => {
test('validates and normalises a safe endpoint', () => {
const endpoint = validateOutboundEndpointUrl('https://api.example.com/v1', {
name: 'test.endpoint',
allowHttp: false,
allowLocalhost: false,
allowPrivateIpLiterals: false,
});
expect(buildEndpointUrl(endpoint, '/users/@me')).toBe('https://api.example.com/v1/users/@me');
});
test('rejects localhost when not allowed', () => {
expect(() =>
validateOutboundEndpointUrl('http://localhost:8088', {
name: 'test.endpoint',
allowHttp: true,
allowLocalhost: false,
allowPrivateIpLiterals: true,
}),
).toThrow('cannot use localhost');
});
test('rejects private IP literals when not allowed', () => {
expect(() =>
validateOutboundEndpointUrl('http://192.168.1.8:8080', {
name: 'test.endpoint',
allowHttp: true,
allowLocalhost: true,
allowPrivateIpLiterals: false,
}),
).toThrow('private or special IP literals');
});
test('rejects absolute outbound paths', () => {
const endpoint = validateOutboundEndpointUrl('https://api.example.com', {
name: 'test.endpoint',
allowHttp: false,
allowLocalhost: false,
allowPrivateIpLiterals: false,
});
expect(() => buildEndpointUrl(endpoint, 'https://evil.example.com')).toThrow('Outbound path must be relative');
});
});