mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
refactor: remove dead code (#3337)
This commit is contained in:
1 parent
a19002652b
commit
93d1f8af46
1527 files changed
+21170
-73846
No files matched your search
@@ -1,43 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
|
||||
import {matchesAnyExactOrNestedPath} from '@fluxer/hono/src/middleware/utils/PathMatchers';
|
||||
import type {MiddlewareHandler} from 'hono';
|
||||
|
||||
interface InternalAuthOptions {
|
||||
secret: string;
|
||||
skipPaths?: Array<string>;
|
||||
}
|
||||
|
||||
function timingSafeCompare(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
if (bufferA.length !== bufferB.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(bufferA, bufferB);
|
||||
}
|
||||
|
||||
export function createInternalAuth(options: InternalAuthOptions): MiddlewareHandler {
|
||||
const {secret, skipPaths = ['/_health']} = options;
|
||||
return async (c, next) => {
|
||||
const path = c.req.path;
|
||||
if (matchesAnyExactOrNestedPath(path, skipPaths)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
throw new UnauthorizedError({message: 'Missing Authorization header'});
|
||||
}
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError({message: 'Invalid Authorization header format'});
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
if (!timingSafeCompare(token, secret)) {
|
||||
throw new UnauthorizedError({message: 'Invalid token'});
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface RateLimitService {
|
||||
checkLimit(params: {identifier: string; maxAttempts: number; windowMs: number}): Promise<RateLimitResult>;
|
||||
}
|
||||
|
||||
export type KeyGenerator = (request: Request) => string | null | Promise<string | null>;
|
||||
type KeyGenerator = (request: Request) => string | null | Promise<string | null>;
|
||||
|
||||
export interface RateLimitOptions {
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {randomUUID} from 'node:crypto';
|
||||
import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import type {MiddlewareHandler} from 'hono';
|
||||
|
||||
export type RequestIdGenerator = () => string;
|
||||
type RequestIdGenerator = () => string;
|
||||
|
||||
export interface RequestIdOptions {
|
||||
headerName?: string;
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createErrorHandler} from '@fluxer/hono/src/middleware/ErrorHandler';
|
||||
import {createInternalAuth} from '@fluxer/hono/src/middleware/InternalAuth';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
interface ErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
describe('InternalAuth Middleware', () => {
|
||||
const TEST_SECRET = 'test-secret-token';
|
||||
describe('missing authorization', () => {
|
||||
test('returns 401 when no Authorization header is present', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test');
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Missing Authorization header');
|
||||
});
|
||||
});
|
||||
describe('invalid authorization format', () => {
|
||||
test('returns 401 when Authorization header does not start with Bearer', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: 'Basic dXNlcjpwYXNz',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Invalid Authorization header format');
|
||||
});
|
||||
test('returns 401 when Authorization header is just Bearer without token', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: 'Bearer',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Invalid Authorization header format');
|
||||
});
|
||||
});
|
||||
describe('invalid token', () => {
|
||||
test('returns 401 when token does not match secret', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: 'Bearer wrong-token',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Invalid token');
|
||||
});
|
||||
test('returns 401 when token is empty after Bearer prefix', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: 'Bearer ',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Invalid Authorization header format');
|
||||
});
|
||||
test('returns 401 when token has different length than secret', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: 'Bearer short',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
expect(body.message).toBe('Invalid token');
|
||||
});
|
||||
});
|
||||
describe('valid token', () => {
|
||||
test('allows request through when token matches secret', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_SECRET}`,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
test('allows POST requests with valid token', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.post('/test', (c) => c.json({created: true}));
|
||||
const response = await app.request('/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_SECRET}`,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
created: boolean;
|
||||
};
|
||||
expect(body.created).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('skipPaths option', () => {
|
||||
test('skips auth for exact path match in skipPaths', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/public', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/public');
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
test('skips auth for paths starting with skipPath prefix', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/public/nested', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/public/nested');
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
test('does not skip auth for paths that do not match skipPaths', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/private', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/private');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
test('supports multiple skipPaths', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public', '/health']}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/public', (c) => c.json({ok: true}));
|
||||
app.get('/health', (c) => c.json({ok: true}));
|
||||
app.get('/private', (c) => c.json({ok: true}));
|
||||
const publicResponse = await app.request('/public');
|
||||
expect(publicResponse.status).toBe(200);
|
||||
const healthResponse = await app.request('/health');
|
||||
expect(healthResponse.status).toBe(200);
|
||||
const privateResponse = await app.request('/private');
|
||||
expect(privateResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
describe('default skipPaths', () => {
|
||||
test('default skipPaths includes /_health', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/_health', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/_health');
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
test('default skipPaths allows nested /_health paths', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/_health/detailed', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/_health/detailed');
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
test('default skipPaths still requires auth for non-health paths', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/api', (c) => c.json({ok: true}));
|
||||
const response = await app.request('/api');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
describe('multiple routes', () => {
|
||||
test('handles multiple routes with proper auth', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/api/users', (c) => c.json({users: []}));
|
||||
app.get('/api/posts', (c) => c.json({posts: []}));
|
||||
const usersResponse = await app.request('/api/users', {
|
||||
headers: {Authorization: `Bearer ${TEST_SECRET}`},
|
||||
});
|
||||
expect(usersResponse.status).toBe(200);
|
||||
const postsResponse = await app.request('/api/posts', {
|
||||
headers: {Authorization: `Bearer ${TEST_SECRET}`},
|
||||
});
|
||||
expect(postsResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
describe('error recovery', () => {
|
||||
test('does not affect subsequent successful requests', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', createInternalAuth({secret: TEST_SECRET}));
|
||||
app.onError(createErrorHandler());
|
||||
app.get('/test', (c) => c.json({ok: true}));
|
||||
const errorResponse = await app.request('/test');
|
||||
expect(errorResponse.status).toBe(401);
|
||||
const successResponse = await app.request('/test', {
|
||||
headers: {Authorization: `Bearer ${TEST_SECRET}`},
|
||||
});
|
||||
expect(successResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,3 @@ export function matchesAnyPathPattern(path: string, patterns: Array<string>): bo
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchesExactOrNestedPath(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
export function matchesAnyExactOrNestedPath(path: string, prefixes: Array<string>): boolean {
|
||||
for (const prefix of prefixes) {
|
||||
if (matchesExactOrNestedPath(path, prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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;
|
||||
|
||||
interface CreateCsrfProtectionOptions {
|
||||
secretKeyBase: string;
|
||||
secureCookie: boolean;
|
||||
cookiePath?: string;
|
||||
cookieSameSite?: 'Strict' | 'Lax' | 'None';
|
||||
ignoredPathSuffixes?: Array<string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isIPv4} from 'node:net';
|
||||
import {getIpNetworkKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 address = getIpNetworkKey(hostname, {ipv4PrefixLength: 'exact', ipv6PrefixLength: 'exact'});
|
||||
if (address === null) {
|
||||
return false;
|
||||
}
|
||||
if (isIPv4(address)) {
|
||||
return isPrivateOrSpecialIPv4(address);
|
||||
}
|
||||
return isPrivateOrSpecialIPv6(address);
|
||||
}
|
||||
|
||||
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(address: string): boolean {
|
||||
if (address === '::' || address === '::1') {
|
||||
return true;
|
||||
}
|
||||
const firstGroup = Number.parseInt(address.split(':', 1)[0] || '0', 16);
|
||||
return (firstGroup & 0xffc0) === 0xfe80 || (firstGroup & 0xfe00) === 0xfc00 || (firstGroup & 0xff00) === 0xff00;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user