diff --git a/cmd/server.ts b/cmd/server.ts index 49660b54..4155b4e2 100644 --- a/cmd/server.ts +++ b/cmd/server.ts @@ -12,6 +12,14 @@ import { runPlug } from "../cmd/plug_run.ts"; import { PrefixedKvPrimitives } from "$lib/data/prefixed_kv_primitives.ts"; import { sleep } from "$lib/async.ts"; +export type AuthOptions = { + authToken?: string; + user: string; + pass: string; + lockoutTime: number; + lockoutLimit: number; +}; + export async function serveCommand( options: { hostname?: string; @@ -63,10 +71,29 @@ export async function serveCommand( const userAuth = options.user ?? Deno.env.get("SB_USER"); - let userCredentials: { user: string; pass: string } | undefined; + let userCredentials: AuthOptions | undefined; if (userAuth) { const [user, pass] = userAuth.split(":"); - userCredentials = { user, pass }; + userCredentials = { + user, + pass, + // 10 failed login attempts in 1 minute + lockoutLimit: 10, + lockoutTime: 60, + }; + // Override lockout settings if they are set in the environment + if (Deno.env.get("SB_LOCKOUT_LIMIT")) { + userCredentials.lockoutLimit = Number(Deno.env.get("SB_LOCKOUT_LIMIT")); + } + if (Deno.env.get("SB_LOCKOUT_TIME")) { + userCredentials.lockoutTime = Number(Deno.env.get("SB_LOCKOUT_TIME")); + } + if (Deno.env.get("SB_AUTH_TOKEN")) { + userCredentials.authToken = Deno.env.get("SB_AUTH_TOKEN"); + } + console.log( + `User authentication enabled for user "${user}" with lockout limit ${userCredentials.lockoutLimit} and lockout time ${userCredentials.lockoutTime}s`, + ); } const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; @@ -125,7 +152,6 @@ export async function serveCommand( certFile: options.cert, auth: userCredentials, - authToken: Deno.env.get("SB_AUTH_TOKEN"), syncOnly, readOnly, shellBackend: backendConfig, diff --git a/server/http_server.ts b/server/http_server.ts index 14a5db52..a8ec78de 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -21,6 +21,7 @@ import { } from "@silverbulletmd/silverbullet/lib/page_ref"; import { base64Encode } from "$lib/crypto.ts"; import { LockoutTimer } from "./lockout.ts"; +import type { AuthOptions } from "../cmd/server.ts"; const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week @@ -32,11 +33,8 @@ export type ServerOptions = { baseKvPrimitives: KvPrimitives; certFile?: string; keyFile?: string; - - // Enable username/password auth - auth?: { user: string; pass: string }; - // Additional API auth token - authToken?: string; + // Enable username/password/token auth + auth?: AuthOptions; pagesPath: string; shellBackend: string; syncOnly: boolean; @@ -315,7 +313,15 @@ export class HttpServer { "/logo.png", "/.auth", ]; - const lockoutTimer = new LockoutTimer(); + + // Since we're a single user app, we can use a single lockout timer to prevent brute force attacks + const lockoutTimer = this.options.auth?.lockoutLimit + ? new LockoutTimer( + // Turn into ms + this.options.auth.lockoutTime * 1000, + this.options.auth.lockoutLimit!, + ) + : new LockoutTimer(0, 0); // disabled // TODO: This should probably be a POST request this.app.get("/.logout", (c) => { @@ -397,7 +403,7 @@ export class HttpServer { // Check auth this.app.use("*", async (c, next) => { const req = c.req; - if (!this.spaceServer.auth && !this.spaceServer.authToken) { + if (!this.spaceServer.auth) { // Auth disabled in this config, skip return next(); } @@ -414,12 +420,12 @@ export class HttpServer { if (!excludedPaths.includes(url.pathname)) { const authCookie = getCookie(c, authCookieName(host)); - if (!authCookie && this.spaceServer.authToken) { + if (!authCookie && this.spaceServer.auth?.authToken) { // Attempt Bearer Authorization based authentication const authHeader = req.header("Authorization"); if (authHeader && authHeader.startsWith("Bearer ")) { const authToken = authHeader.slice("Bearer ".length); - if (authToken === this.spaceServer.authToken) { + if (authToken === this.spaceServer.auth.authToken) { // All good, let's proceed this.refreshLogin(c, host); return next(); diff --git a/server/lockout.test.ts b/server/lockout.test.ts index 6d849a43..4e0e2a3b 100644 --- a/server/lockout.test.ts +++ b/server/lockout.test.ts @@ -5,35 +5,14 @@ import { FakeTime } from "@std/testing/time"; const lockoutTime = 60000; const lockoutLimit = 10; -Deno.test("Lockout - failed login rate limiter", async () => { - const envLockoutTime = Deno.env.get("SB_LOCKOUT_TIME_MS") || ""; - const envLockoutLimit = Deno.env.get("SB_LOCKOUT_LIMIT") || ""; - +Deno.test("Lockout - failed login rate limiter", () => { using time = new FakeTime(); testDisabled(new LockoutTimer(NaN, lockoutLimit), "by period"); testDisabled(new LockoutTimer(lockoutTime, NaN), "by limit"); - Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime)); - Deno.env.set("SB_LOCKOUT_LIMIT", ""); - testDisabled(new LockoutTimer(lockoutTime, NaN), "by env period"); - - Deno.env.set("SB_LOCKOUT_TIME_MS", ""); - Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit)); - testDisabled(new LockoutTimer(lockoutTime, NaN), "by env limit"); - - Deno.env.set("SB_LOCKOUT_TIME_MS", ""); - Deno.env.set("SB_LOCKOUT_LIMIT", ""); - await testLockout(new LockoutTimer(lockoutTime, 10), "explicit params"); - await testLockoutPerMS(new LockoutTimer(lockoutTime, 10), "explicit params"); - - Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime)); - Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit)); - await testLockout(new LockoutTimer(), "params from env"); - await testLockoutPerMS(new LockoutTimer(), "params from env"); - - Deno.env.set("SB_LOCKOUT_TIME_MS", envLockoutTime); - Deno.env.set("SB_LOCKOUT_LIMIT", envLockoutLimit); + testLockout(new LockoutTimer(lockoutTime, 10), "explicit params"); + testLockoutPerMS(new LockoutTimer(lockoutTime, 10), "explicit params"); function testDisabled(timer: LockoutTimer, txt: string) { for (let i = 0; i < 100; i++) { diff --git a/server/lockout.ts b/server/lockout.ts index 5718d386..e9e54de7 100644 --- a/server/lockout.ts +++ b/server/lockout.ts @@ -11,8 +11,8 @@ export class LockoutTimer { disabled: boolean; constructor( - countPeriodMs: number = Number(Deno.env.get("SB_LOCKOUT_TIME_MS")) || NaN, - limit: number = Number(Deno.env.get("SB_LOCKOUT_LIMIT")) || NaN, + countPeriodMs: number, + limit: number, ) { this.disabled = isNaN(countPeriodMs) || isNaN(limit) || countPeriodMs < 1 || limit < 1; diff --git a/server/space_server.ts b/server/space_server.ts index 68ed6f2e..7c30e066 100644 --- a/server/space_server.ts +++ b/server/space_server.ts @@ -25,12 +25,12 @@ import { defaultConfig, } from "../type/config.ts"; import type { ServerOptions } from "./http_server.ts"; +import type { AuthOptions } from "../cmd/server.ts"; // Equivalent of Client on the server export class SpaceServer implements ConfigContainer { public pagesPath: string; - auth?: { user: string; pass: string }; - authToken?: string; + auth?: AuthOptions; hostname: string; config: Config; @@ -54,7 +54,6 @@ export class SpaceServer implements ConfigContainer { this.pagesPath = options.pagesPath; this.hostname = options.hostname; this.auth = options.auth; - this.authToken = options.authToken; this.syncOnly = options.syncOnly; this.readOnly = options.readOnly; this.config = defaultConfig; @@ -115,7 +114,7 @@ export class SpaceServer implements ConfigContainer { if (this.auth) { // Initialize JWT issuer await this.jwtIssuer.init( - JSON.stringify({ auth: this.auth, authToken: this.authToken }), + JSON.stringify({ auth: this.auth }), ); } diff --git a/website/Authentication.md b/website/Authentication.md index 86cbfd18..ff454a1a 100644 --- a/website/Authentication.md +++ b/website/Authentication.md @@ -1,6 +1,6 @@ SilverBullet supports simple authentication for a single user. -By simply passing the `--user` flag with a username:password combination, you enable authentication for a single user. For instance: +By passing the `--user` flag with a username:password combination, you enable authentication for a single user. For instance: ```shell silverbullet --user pete:1234 . @@ -8,10 +8,4 @@ silverbullet --user pete:1234 . Will let `pete` authenticate with password `1234`. -Alternatively, the same information can be passed in via the `SB_USER` environment variable, e.g. - -```shell -SB_USER=pete:1234 silverbullet . -``` - -This is especially convenient when deploying using Docker +Authentication can also be configured via environment variables (which offer a bit more flexibility), see [[Install/Configuration#Authentication]]. diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index d9feeb2d..8839a76a 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -4,9 +4,8 @@ An attempt at documenting the changes/new features introduced in each release. ## Edge _These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._ -* Nothing yet since 0.10.0. Stay tuned! - [[CHANGELOG]] +* (Security) Implemented a lockout mechanism after a number of failed login attempts for [[Authentication]] (configured via [[Install/Configuration#Authentication]]) (by [Peter Weston](https://github.com/silverbulletmd/silverbullet/pull/1152)) ## 0.10.1 This is a “major” release primarily because of the underlying migration to rely on Deno 2 and a bunch of foundational work that’s not really leveraged yet. Stay tuned for more. diff --git a/website/Install/Configuration.md b/website/Install/Configuration.md index b5a2603b..256b9ad8 100644 --- a/website/Install/Configuration.md +++ b/website/Install/Configuration.md @@ -11,6 +11,8 @@ SilverBullet supports basic authentication for a single user. * `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”. * `SB_AUTH_TOKEN`: Enables `Authorization: Bearer ` style authentication on the [[API]] (useful for [[Sync]] and remote HTTP storage backends). +* `SB_LOCKOUT_LIMIT`: Specifies the number of failed login attempt before locking the user out (for a `SB_LOCKOUT_TIME` specified amount of seconds), defaults to `10` +* `SB_LOCKOUT_TIME`: Specifies the amount of time (in seconds) a client will be blocked until attempting to log back in. # Storage SilverBullet supports multiple storage backends for keeping your [[Spaces]] content.