Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/endpoints/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export default function debug(token: string | undefined) {
)) as Promise<Record<string, never>>;
};

const dataRefresh = async () => {
return json(apiFetch(
'POST',
'/api/debug/data/refresh',
token,
)) as Promise<Record<string, never>>;
};

return {
/**
* Reset the app to its default state.
Expand All @@ -28,5 +36,9 @@ export default function debug(token: string | undefined) {
* Echo text to the server's console
*/
echo,
/**
* Invalidate cached data
*/
dataRefresh,
};
}
2 changes: 1 addition & 1 deletion src/endpoints/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { browser } from '$app/environment';

export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'DELETE';

function getUrl() {
export function getUrl() {
if (browser) {
// Running in browser (request to whatever origin we are running in)
return '';
Expand Down
2 changes: 2 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import logger from './middleware/logger';
import banMiddleware from './middleware/bans';

const middleware: Handle[] = [];

middleware.push(banMiddleware);
middleware.push(logger());

export const handle = sequence(...middleware);
120 changes: 120 additions & 0 deletions src/lib/server/auth/fail2ban.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* A simple fail2ban implementation, which records the most recent `n` failed
* login attempts for each IP address.
*/

import { unixTime } from '$lib/util';
import { error } from '@sveltejs/kit';
import { getLocalConfig, setLocalConfig } from '../data/localConfig';

/**
* The number of failed logins originating from an IP address required before
* it is banned.
*/
const FAILS_UNTIL_BAN = 25;

/**
* The duration for which a ban should last on an IP address.
*/
const BAN_DURATION = 60 * 60 * 24; // 24 hours

/** Filter expired failures from the list */
function filterExpiredLogs(failTimestamps: number[]): number[] {
const time = unixTime();
return failTimestamps.filter(t => t >= time - BAN_DURATION);
}

/** Returns whether the given IP address is banned from attempting logins */
export async function isIpBanned(ip: string) {
const config = await getLocalConfig();
if (!(ip in config.loginBannedIps)) {
// By default, IP addresses are not banned
return false;
}
// Check for explicit bans
if (typeof config.loginBannedIps[ip] === 'boolean') {
return config.loginBannedIps[ip];
}

// If fail2ban is disabled, skip this logic
if (!config.enableFail2ban) {
return false;
}

const failTimestamps = config.loginBannedIps[ip];
// IP has failed to log in too many times
if (failTimestamps.length >= FAILS_UNTIL_BAN) {
// Ban is still active if last failed login is longer than BAN_DURATIOn ago
if (failTimestamps.at(-1) ?? Number.POSITIVE_INFINITY < unixTime() - BAN_DURATION) {
return true;
}
// Otherwise, reset the expired logins, since the ban has finished
delete config.loginBannedIps[ip];
await setLocalConfig(config);
}
// IP is not banned, hasn't failed enough times
return false;
}

/** Notify fail2ban of a failed login */
export async function notifyFailedLogin(ip: string) {
const config = await getLocalConfig();

// Explicitly banned/allowed IPs require no action
if (typeof config.loginBannedIps[ip] === 'boolean') {
return;
}

// No action if fail2ban is disabled
if (!config.enableFail2ban) {
return;
}

// Filter expired login failures from the list
const failTimestamps = filterExpiredLogs(config.loginBannedIps[ip] ?? []);
// Then push the new failure time to the end
failTimestamps.push(unixTime());

config.loginBannedIps[ip] = failTimestamps;
await setLocalConfig(config);
}

/** Remove ban/allow from IP */
export async function unbanIp(ip: string) {
const config = await getLocalConfig();

if (!(ip in config.loginBannedIps)) {
error(400, 'This IP address has not been configured');
}

delete config.loginBannedIps[ip];

await setLocalConfig(config);
}

/** Permanently ban an IP address */
export async function banIp(ip: string) {
const config = await getLocalConfig();
config.loginBannedIps[ip] = true;
await setLocalConfig(config);
}

/** Allow all login attempts from an IP */
export async function allowIp(ip: string) {
const config = await getLocalConfig();
config.loginBannedIps[ip] = false;
await setLocalConfig(config);
}

/** Get whether fail2ban is enabled */
export async function isFail2banEnabled() {
const config = await getLocalConfig();
return config.enableFail2ban;
}

/** Set whether fail2ban is enabled */
export async function setFail2Ban(newState: boolean) {
const config = await getLocalConfig();
config.enableFail2ban = newState;
await setLocalConfig(config);
}
2 changes: 1 addition & 1 deletion src/lib/server/auth/passwords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function hashAndSalt(salt: string, password: string): string {
export async function validateCredentials(
username: string,
password: string,
code = 403,
code = 401,
): Promise<string> {
const local = await getLocalConfig();

Expand Down
4 changes: 4 additions & 0 deletions src/lib/server/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export async function authSetup(
}
},
},
enableFail2ban: false,
loginBannedIps: {},
bannedIps: [],
bannedUserAgents: [],
keyPath: null,
version,
};
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server/auth/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export async function validateTokenFromRequest(req: { request: Request, cookies:
error(401, 'A token is required to access this endpoint');
}
const data = await validateToken(token).catch(e => {
console.log(e);
// console.log(e);
// Remove token from cookies, as it is invalid
req.cookies.delete('token', { path: '/' });
error(401, `${e}`);
Expand Down
27 changes: 26 additions & 1 deletion src/lib/server/data/localConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, writeFile } from 'fs/promises';
import { nullable, number, object, record, string, validate, type Infer } from 'superstruct';
import { array, boolean, nullable, number, object, record, string, union, validate, type Infer } from 'superstruct';
import { getPrivateDataDir } from './dataDir';

/** Path to config.local.json */
Expand Down Expand Up @@ -47,6 +47,31 @@ export const ConfigLocalJsonStruct = object({
revokedSessions: record(string(), number()),
})
})),
/**
* Whether to enable fail2ban, where IP addresses that fail to log in are
* banned.
*
* If this is set to `true`, the server will store an array of timestamps for
* login fails for incoming IP addresses, and if that IP has a login failure
* too often, it will be banned from attempting to log in temporarily.
*/
enableFail2ban: boolean(),
/**
* A mapping of IP addresses to the array of their most recent login fail
* timestamps, or a boolean indicating whether they are permanently banned
* (`true`) or must never be banned (`false`).
*/
loginBannedIps: record(string(), union([array(number()), boolean()])),
/**
* Array of banned IP addresses. All requests from these IP addresses will
* be blocked.
*/
bannedIps: array(string()),
/**
* Array of regular expressions matching user-agent strings which should be
* blocked.
*/
bannedUserAgents: array(string()),
/**
* Path to the private key file which the server should use when connecting
* to git repos.
Expand Down
6 changes: 2 additions & 4 deletions src/lib/server/data/migrations/v0.3.0.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/** Migration from 0.3.0 -> current */

import { updateConfigVersions } from './shared';
import { setConfig, type ConfigJson } from '../config';
import { listGroups, setGroupInfo, type GroupInfo } from '../group';
import { listItems, setItemInfo, type ItemInfoFull } from '../item';
import { moveLocalConfig } from './shared';
import { unsafeLoadConfig, unsafeLoadGroupInfo, unsafeLoadItemInfo } from './unsafeLoad';
import migrateV050 from './v0.5.0';

export default async function migrate(dataDir: string, privateDataDir: string) {
await migrateConfig(dataDir);
Expand All @@ -16,8 +15,7 @@ export default async function migrate(dataDir: string, privateDataDir: string) {
}
}

await moveLocalConfig(dataDir, privateDataDir);
await updateConfigVersions();
await migrateV050(dataDir, privateDataDir);
}

async function migrateConfig(dataDir: string) {
Expand Down
8 changes: 8 additions & 0 deletions src/lib/server/data/migrations/v0.5.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* * Move `config.local.json` to the new private data directory.
* * Re-structure user auth info to support multiple users in the future
* * Add more properties for banning IPs and user-agents
*/

import { nanoid } from 'nanoid';
Expand All @@ -31,6 +32,13 @@ async function updateLocalConfig(privateDataDir: string) {
return;
}

// Add IP/user-agent ban fields
config.enableFail2ban = true;
config.loginBannedIps = {};
config.bannedIps = [];
config.bannedUserAgents = [];

// Add SSH key path field
config.keyPath = null;

const userInfo = config.auth;
Expand Down
32 changes: 32 additions & 0 deletions src/middleware/bans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { authIsSetUp } from '$lib/server/data/dataDir';
import { getLocalConfig } from '$lib/server/data/localConfig';
import { error, type Handle } from '@sveltejs/kit';

const banMiddleware: Handle = async (req) => {
if (
// Allow all requests if server isn't set up
!await authIsSetUp()
// Allow all requests to debug endpoints
|| req.event.url.pathname.startsWith('/api/debug')
) {
return req.resolve(req.event);
}
const config = await getLocalConfig();
const ip = req.event.getClientAddress();
if (config.bannedIps.includes(ip)) {
error(403, 'This IP address is banned');
}
const userAgent = req.event.request.headers.get('User-Agent');
if (!userAgent) {
error(403, 'Please set a User-Agent header identifying your application.');
}
for (const regex of config.bannedUserAgents) {
if (RegExp(regex).test(userAgent)) {
error(403, 'Your User-Agent is banned');
}
}
// They are fine, let them access the site
return req.resolve(req.event);
}

export default banMiddleware;
19 changes: 15 additions & 4 deletions src/routes/api/admin/auth/login/+server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { isIpBanned, notifyFailedLogin } from '$lib/server/auth/fail2ban.js';
import { validateCredentials } from '$lib/server/auth/passwords';
import { generateToken } from '$lib/server/auth/tokens';
import { authIsSetUp } from '$lib/server/data/dataDir.js';
import { error, json } from '@sveltejs/kit';


export async function POST({ request, cookies }: import('./$types.js').RequestEvent) {
export async function POST(req: import('./$types.js').RequestEvent) {
if (!await authIsSetUp()) error(400, 'Auth is not set up yet');

const { username, password } = await request.json();
if (await isIpBanned(req.getClientAddress())) {
error(403, 'IP address is banned');
}

const uid = await validateCredentials(username, password);
const { username, password } = await req.request.json();

return json({ token: await generateToken(uid, cookies) }, { status: 200 });
let uid: string;
try {
uid = await validateCredentials(username, password);
} catch (e) {
await notifyFailedLogin(req.getClientAddress());
throw e;
}

return json({ token: await generateToken(uid, req.cookies) }, { status: 200 });
}
16 changes: 16 additions & 0 deletions src/routes/api/debug/data/refresh/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* POST /api/debug/refresh
*
* Manually invalidate portfolio globals. Very similar to
* POST /api/admin/data/refresh, but without authentication required.
*/
import { dev } from '$app/environment';
import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index';
import { error, json } from '@sveltejs/kit';

export async function POST() {
if (!dev) error(404);
await getPortfolioGlobals().catch(e => error(400, e));
invalidatePortfolioGlobals();
return json({}, { status: 200 });
}
4 changes: 3 additions & 1 deletion tests/backend/admin/auth/disable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ it('Disables authentication', async () => {
await expect(api.admin.auth.disable(username, password)).resolves.toStrictEqual({});
// Logging in should fail
await expect(api.admin.auth.login(username, password))
.rejects.toMatchObject({ code: 403 });
// This is a 401 because we are partially migrated to a multi-user setup,
// so this is equivalent to an incorrect username
.rejects.toMatchObject({ code: 401 });
// And any operation using the token should also fail
// Technically, this should be a 403, since no value can ever be successful,
// but I don't want to add another code path
Expand Down
Loading