nca-ai-cms-astro-plugin 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nca-ai-cms-astro-plugin",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -1,14 +1,35 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { z } from 'zod';
3
3
  import { jsonResponse, jsonError } from '../_utils.js';
4
- import { getEnvVariable } from '../../utils/envUtils.js';
4
+ import { verifyCredentials } from '../../utils/credentialUtils.js';
5
+ import { createSession, purgeExpiredSessions } from '../../services/SessionService.js';
6
+ import { loginRateLimiter } from '../../utils/loginRateLimiter.js';
5
7
 
6
8
  const loginSchema = z.object({
7
9
  username: z.string().min(1),
8
10
  password: z.string().min(1),
9
11
  });
10
12
 
11
- export const POST: APIRoute = async ({ request, cookies }) => {
13
+ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => {
14
+ const ip =
15
+ clientAddress ??
16
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
17
+ 'unknown';
18
+
19
+ const { limited, retryAfter } = loginRateLimiter.check(ip);
20
+ if (limited) {
21
+ return new Response(
22
+ JSON.stringify({ error: 'Too many login attempts. Try again later.' }),
23
+ {
24
+ status: 429,
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Retry-After': String(retryAfter),
28
+ },
29
+ },
30
+ );
31
+ }
32
+
12
33
  let body: unknown;
13
34
  try {
14
35
  body = await request.json();
@@ -22,14 +43,18 @@ export const POST: APIRoute = async ({ request, cookies }) => {
22
43
  }
23
44
 
24
45
  const { username, password } = result.data;
25
- const expectedUsername = getEnvVariable('EDITOR_ADMIN');
26
- const expectedPassword = getEnvVariable('EDITOR_PASSWORD');
27
46
 
28
- if (username !== expectedUsername || password !== expectedPassword) {
47
+ if (!verifyCredentials(username, password)) {
48
+ loginRateLimiter.record(ip);
49
+ console.warn(
50
+ `[nca-ai-cms] Failed login attempt from ${ip} at ${new Date().toISOString()}`,
51
+ );
29
52
  return jsonError('Invalid credentials', 401);
30
53
  }
31
54
 
32
- const token = btoa(`${username}:${password}`);
55
+ loginRateLimiter.clear(ip);
56
+ await purgeExpiredSessions();
57
+ const token = await createSession();
33
58
 
34
59
  cookies.set('editor-auth', token, {
35
60
  httpOnly: true,
@@ -1,6 +1,10 @@
1
1
  import type { APIRoute } from 'astro';
2
+ import { deleteSession } from '../../services/SessionService.js';
2
3
 
3
4
  export const POST: APIRoute = async ({ cookies, redirect }) => {
5
+ const token = cookies.get('editor-auth')?.value;
6
+ if (token) await deleteSession(token);
7
+
4
8
  cookies.delete('editor-auth', { path: '/' });
5
9
  return redirect('/login', 302);
6
10
  };
package/src/db/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineDb } from 'astro:db';
2
- import { SiteSettings, Prompts, ScheduledPosts } from './tables.js';
2
+ import { SiteSettings, Prompts, ScheduledPosts, Sessions } from './tables.js';
3
3
 
4
4
  export default defineDb({
5
- tables: { SiteSettings, Prompts, ScheduledPosts },
5
+ tables: { SiteSettings, Prompts, ScheduledPosts, Sessions },
6
6
  });
package/src/db/tables.ts CHANGED
@@ -37,4 +37,12 @@ const Prompts = defineTable({
37
37
  },
38
38
  });
39
39
 
40
- export { SiteSettings, Prompts, ScheduledPosts };
40
+ const Sessions = defineTable({
41
+ columns: {
42
+ token: column.text({ primaryKey: true }),
43
+ createdAt: column.date({ default: new Date() }),
44
+ expiresAt: column.date(),
45
+ },
46
+ });
47
+
48
+ export { SiteSettings, Prompts, ScheduledPosts, Sessions };
package/src/middleware.ts CHANGED
@@ -16,7 +16,7 @@ export const onRequest = defineMiddleware(async ({ request, cookies, redirect }:
16
16
 
17
17
  const authCookie = cookies.get('editor-auth')?.value;
18
18
 
19
- if (isAuthenticated(authCookie)) {
19
+ if (await isAuthenticated(authCookie)) {
20
20
  return next();
21
21
  }
22
22
 
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ const mockGet = vi.fn();
4
+ const mockInsertValues = vi.fn();
5
+ const mockDeleteWhere = vi.fn();
6
+ const mockSelectFrom = vi.fn();
7
+
8
+ vi.mock('astro:db', () => {
9
+ const eq = vi.fn((col: unknown, val: unknown) => ({ col, val }));
10
+ return {
11
+ eq,
12
+ Sessions: { token: 'Sessions.token' },
13
+ db: {
14
+ insert: vi.fn(() => ({ values: mockInsertValues })),
15
+ delete: vi.fn(() => ({ where: mockDeleteWhere })),
16
+ select: vi.fn(() => ({
17
+ from: vi.fn(() => ({
18
+ where: vi.fn(() => ({ get: mockGet })),
19
+ // Direct iteration for purgeExpiredSessions (selectAll)
20
+ })),
21
+ })),
22
+ },
23
+ };
24
+ });
25
+
26
+ // Re-mock select to support both .get() chain and direct array return
27
+ const { db } = await import('astro:db');
28
+
29
+ describe('SessionService', () => {
30
+ beforeEach(() => {
31
+ vi.useFakeTimers();
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.useRealTimers();
37
+ });
38
+
39
+ describe('createSession', () => {
40
+ it('inserts a session row and returns a UUID token', async () => {
41
+ const { createSession } = await import('./SessionService.js');
42
+ mockInsertValues.mockResolvedValue(undefined);
43
+
44
+ const token = await createSession();
45
+
46
+ expect(token).toMatch(
47
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
48
+ );
49
+ expect(db.insert).toHaveBeenCalled();
50
+ expect(mockInsertValues).toHaveBeenCalledWith(
51
+ expect.objectContaining({
52
+ token,
53
+ createdAt: expect.any(Date),
54
+ expiresAt: expect.any(Date),
55
+ }),
56
+ );
57
+ });
58
+
59
+ it('sets expiresAt to 24 hours from now', async () => {
60
+ const { createSession } = await import('./SessionService.js');
61
+ mockInsertValues.mockResolvedValue(undefined);
62
+
63
+ const now = new Date('2026-03-21T12:00:00Z');
64
+ vi.setSystemTime(now);
65
+
66
+ await createSession();
67
+
68
+ const call = mockInsertValues.mock.calls[0]?.[0];
69
+ const expiresAt = new Date(call.expiresAt);
70
+ const expected = new Date('2026-03-22T12:00:00Z');
71
+ expect(expiresAt.getTime()).toBe(expected.getTime());
72
+ });
73
+ });
74
+
75
+ describe('validateSession', () => {
76
+ it('returns true for a valid unexpired session', async () => {
77
+ const { validateSession } = await import('./SessionService.js');
78
+ const future = new Date(Date.now() + 60 * 60 * 1000);
79
+ mockGet.mockResolvedValue({ token: 'abc', expiresAt: future });
80
+
81
+ const result = await validateSession('abc');
82
+ expect(result).toBe(true);
83
+ });
84
+
85
+ it('returns false for an expired session', async () => {
86
+ const { validateSession } = await import('./SessionService.js');
87
+ const past = new Date(Date.now() - 1000);
88
+ mockGet.mockResolvedValue({ token: 'abc', expiresAt: past });
89
+
90
+ const result = await validateSession('abc');
91
+ expect(result).toBe(false);
92
+ });
93
+
94
+ it('returns false when session does not exist', async () => {
95
+ const { validateSession } = await import('./SessionService.js');
96
+ mockGet.mockResolvedValue(undefined);
97
+
98
+ const result = await validateSession('nonexistent');
99
+ expect(result).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('deleteSession', () => {
104
+ it('deletes the session row by token', async () => {
105
+ const { deleteSession } = await import('./SessionService.js');
106
+ mockDeleteWhere.mockResolvedValue(undefined);
107
+
108
+ await deleteSession('abc');
109
+
110
+ expect(db.delete).toHaveBeenCalled();
111
+ expect(mockDeleteWhere).toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ describe('purgeExpiredSessions', () => {
116
+ it('deletes expired sessions and keeps valid ones', async () => {
117
+ const { purgeExpiredSessions } = await import('./SessionService.js');
118
+
119
+ const now = new Date();
120
+ const expired = { token: 'old', expiresAt: new Date(now.getTime() - 1000) };
121
+ const valid = { token: 'fresh', expiresAt: new Date(now.getTime() + 60000) };
122
+
123
+ // Override select().from() to return array directly
124
+ (db.select as ReturnType<typeof vi.fn>).mockReturnValueOnce({
125
+ from: vi.fn().mockResolvedValue([expired, valid]),
126
+ });
127
+ mockDeleteWhere.mockResolvedValue(undefined);
128
+
129
+ await purgeExpiredSessions();
130
+
131
+ // Should only delete the expired one
132
+ expect(db.delete).toHaveBeenCalledTimes(1);
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,44 @@
1
+ // @ts-ignore - resolved by Astro build pipeline
2
+ import { db, Sessions, eq } from 'astro:db';
3
+
4
+ const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
5
+
6
+ export async function createSession(): Promise<string> {
7
+ const token = crypto.randomUUID();
8
+ const now = new Date();
9
+ const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS);
10
+
11
+ await db.insert(Sessions).values({
12
+ token,
13
+ createdAt: now,
14
+ expiresAt,
15
+ });
16
+
17
+ return token;
18
+ }
19
+
20
+ export async function validateSession(token: string): Promise<boolean> {
21
+ const row = await db
22
+ .select()
23
+ .from(Sessions)
24
+ .where(eq(Sessions.token, token))
25
+ .get();
26
+
27
+ if (!row) return false;
28
+ return new Date(row.expiresAt) > new Date();
29
+ }
30
+
31
+ export async function deleteSession(token: string): Promise<void> {
32
+ await db.delete(Sessions).where(eq(Sessions.token, token));
33
+ }
34
+
35
+ export async function purgeExpiredSessions(): Promise<void> {
36
+ const all = await db.select().from(Sessions);
37
+ const now = new Date();
38
+
39
+ for (const row of all) {
40
+ if (new Date(row.expiresAt) <= now) {
41
+ await db.delete(Sessions).where(eq(Sessions.token, row.token));
42
+ }
43
+ }
44
+ }
@@ -1,4 +1,11 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const mockValidateSession = vi.fn();
4
+
5
+ vi.mock('../services/SessionService.js', () => ({
6
+ validateSession: (...args: unknown[]) => mockValidateSession(...args),
7
+ }));
8
+
2
9
  import { isPublicPath, isProtectedPath, isAuthenticated } from './authUtils.js';
3
10
 
4
11
  describe('isPublicPath', () => {
@@ -36,30 +43,37 @@ describe('isProtectedPath', () => {
36
43
 
37
44
  describe('isAuthenticated', () => {
38
45
  beforeEach(() => {
39
- process.env.EDITOR_ADMIN = 'admin';
40
- process.env.EDITOR_PASSWORD = 'secret';
46
+ mockValidateSession.mockReset();
41
47
  });
42
48
 
43
- afterEach(() => {
44
- delete process.env.EDITOR_ADMIN;
45
- delete process.env.EDITOR_PASSWORD;
49
+ it('returns true when session is valid', async () => {
50
+ mockValidateSession.mockResolvedValue(true);
51
+ const result = await isAuthenticated('valid-token');
52
+ expect(result).toBe(true);
53
+ expect(mockValidateSession).toHaveBeenCalledWith('valid-token');
46
54
  });
47
55
 
48
- it('returns true for valid base64 credentials', () => {
49
- const token = btoa('admin:secret');
50
- expect(isAuthenticated(token)).toBe(true);
56
+ it('returns false when session is invalid', async () => {
57
+ mockValidateSession.mockResolvedValue(false);
58
+ const result = await isAuthenticated('expired-token');
59
+ expect(result).toBe(false);
51
60
  });
52
61
 
53
- it('returns false for wrong credentials', () => {
54
- const token = btoa('admin:wrong');
55
- expect(isAuthenticated(token)).toBe(false);
62
+ it('returns false for undefined without calling validateSession', async () => {
63
+ const result = await isAuthenticated(undefined);
64
+ expect(result).toBe(false);
65
+ expect(mockValidateSession).not.toHaveBeenCalled();
56
66
  });
57
67
 
58
- it('returns false for undefined', () => {
59
- expect(isAuthenticated(undefined)).toBe(false);
68
+ it('returns false for empty string without calling validateSession', async () => {
69
+ const result = await isAuthenticated('');
70
+ expect(result).toBe(false);
71
+ expect(mockValidateSession).not.toHaveBeenCalled();
60
72
  });
61
73
 
62
- it('returns false for invalid base64', () => {
63
- expect(isAuthenticated('%%%not-base64')).toBe(false);
74
+ it('returns false when validateSession throws', async () => {
75
+ mockValidateSession.mockRejectedValue(new Error('DB error'));
76
+ const result = await isAuthenticated('some-token');
77
+ expect(result).toBe(false);
64
78
  });
65
79
  });
@@ -1,4 +1,4 @@
1
- import { getEnvVariable } from './envUtils.js';
1
+ import { validateSession } from '../services/SessionService.js';
2
2
 
3
3
  const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/logout', '/login'];
4
4
  const PUBLIC_PATH_PREFIXES = ['/api/article-image/'];
@@ -11,15 +11,11 @@ export function isProtectedPath(pathname: string): boolean {
11
11
  return pathname.startsWith('/api/') || pathname === '/editor';
12
12
  }
13
13
 
14
- export function isAuthenticated(cookieValue: string | undefined): boolean {
14
+ export async function isAuthenticated(cookieValue: string | undefined): Promise<boolean> {
15
15
  if (!cookieValue) return false;
16
16
 
17
17
  try {
18
- const decoded = atob(cookieValue);
19
- const [username, password] = decoded.split(':');
20
- const expectedUsername = getEnvVariable('EDITOR_ADMIN');
21
- const expectedPassword = getEnvVariable('EDITOR_PASSWORD');
22
- return username === expectedUsername && password === expectedPassword;
18
+ return await validateSession(cookieValue);
23
19
  } catch {
24
20
  return false;
25
21
  }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ timingSafeStringEqual,
4
+ verifyCredentials,
5
+ generateCookieToken,
6
+ verifyCookieToken,
7
+ } from './credentialUtils.js';
8
+
9
+ describe('timingSafeStringEqual', () => {
10
+ it('returns true for equal strings', () => {
11
+ expect(timingSafeStringEqual('hello', 'hello')).toBe(true);
12
+ });
13
+
14
+ it('returns false for different strings of same length', () => {
15
+ expect(timingSafeStringEqual('hello', 'world')).toBe(false);
16
+ });
17
+
18
+ it('returns false for different lengths', () => {
19
+ expect(timingSafeStringEqual('short', 'much longer string')).toBe(false);
20
+ });
21
+
22
+ it('returns true for empty strings', () => {
23
+ expect(timingSafeStringEqual('', '')).toBe(true);
24
+ });
25
+
26
+ it('handles unicode correctly', () => {
27
+ expect(timingSafeStringEqual('über', 'über')).toBe(true);
28
+ expect(timingSafeStringEqual('über', 'uber')).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('verifyCredentials', () => {
33
+ beforeEach(() => {
34
+ process.env.EDITOR_ADMIN = 'admin';
35
+ process.env.EDITOR_PASSWORD = 'secret';
36
+ });
37
+
38
+ afterEach(() => {
39
+ delete process.env.EDITOR_ADMIN;
40
+ delete process.env.EDITOR_PASSWORD;
41
+ });
42
+
43
+ it('returns true for correct credentials', () => {
44
+ expect(verifyCredentials('admin', 'secret')).toBe(true);
45
+ });
46
+
47
+ it('returns false for wrong password', () => {
48
+ expect(verifyCredentials('admin', 'wrong')).toBe(false);
49
+ });
50
+
51
+ it('returns false for wrong username', () => {
52
+ expect(verifyCredentials('wrong', 'secret')).toBe(false);
53
+ });
54
+
55
+ it('returns false for both wrong', () => {
56
+ expect(verifyCredentials('wrong', 'wrong')).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('generateCookieToken + verifyCookieToken', () => {
61
+ beforeEach(() => {
62
+ process.env.EDITOR_ADMIN = 'admin';
63
+ process.env.EDITOR_PASSWORD = 'secret';
64
+ });
65
+
66
+ afterEach(() => {
67
+ delete process.env.EDITOR_ADMIN;
68
+ delete process.env.EDITOR_PASSWORD;
69
+ });
70
+
71
+ it('round-trips successfully', () => {
72
+ const token = generateCookieToken('admin');
73
+ expect(verifyCookieToken(token)).toBe(true);
74
+ });
75
+
76
+ it('rejects a tampered token', () => {
77
+ const token = generateCookieToken('admin');
78
+ const tampered = token.slice(0, -2) + 'XX';
79
+ expect(verifyCookieToken(tampered)).toBe(false);
80
+ });
81
+
82
+ it('rejects a token for wrong username', () => {
83
+ const token = generateCookieToken('attacker');
84
+ expect(verifyCookieToken(token)).toBe(false);
85
+ });
86
+
87
+ it('rejects invalid base64', () => {
88
+ expect(verifyCookieToken('%%%not-base64')).toBe(false);
89
+ });
90
+
91
+ it('rejects token without colon separator', () => {
92
+ expect(verifyCookieToken(btoa('nocolon'))).toBe(false);
93
+ });
94
+
95
+ it('changes when password changes', () => {
96
+ const token1 = generateCookieToken('admin');
97
+ process.env.EDITOR_PASSWORD = 'different';
98
+ expect(verifyCookieToken(token1)).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,68 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { getEnvVariable } from './envUtils.js';
3
+
4
+ /**
5
+ * Timing-safe string comparison that does not leak length information.
6
+ */
7
+ export function timingSafeStringEqual(a: string, b: string): boolean {
8
+ const bufA = Buffer.from(a, 'utf8');
9
+ const bufB = Buffer.from(b, 'utf8');
10
+
11
+ if (bufA.length !== bufB.length) {
12
+ // Compare against a dummy buffer to avoid timing leak on length mismatch
13
+ const dummy = Buffer.alloc(bufA.length);
14
+ timingSafeEqual(bufA, dummy);
15
+ return false;
16
+ }
17
+
18
+ return timingSafeEqual(bufA, bufB);
19
+ }
20
+
21
+ /**
22
+ * Verify username and password against env vars using timing-safe comparison.
23
+ * Both comparisons always run to prevent short-circuit timing leaks.
24
+ */
25
+ export function verifyCredentials(username: string, password: string): boolean {
26
+ const expectedUsername = getEnvVariable('EDITOR_ADMIN');
27
+ const expectedPassword = getEnvVariable('EDITOR_PASSWORD');
28
+
29
+ const usernameOk = timingSafeStringEqual(username, expectedUsername);
30
+ const passwordOk = timingSafeStringEqual(password, expectedPassword);
31
+
32
+ return usernameOk && passwordOk;
33
+ }
34
+
35
+ /**
36
+ * Generate an HMAC-based cookie token that proves knowledge of credentials
37
+ * without embedding the plain-text password.
38
+ */
39
+ export function generateCookieToken(username: string): string {
40
+ const secret = getEnvVariable('EDITOR_PASSWORD');
41
+ const hmac = createHmac('sha256', secret).update(username).digest('hex');
42
+ return btoa(`${username}:${hmac}`);
43
+ }
44
+
45
+ /**
46
+ * Verify a cookie token by recomputing the HMAC.
47
+ */
48
+ export function verifyCookieToken(token: string): boolean {
49
+ try {
50
+ const decoded = atob(token);
51
+ const colonIndex = decoded.indexOf(':');
52
+ if (colonIndex === -1) return false;
53
+
54
+ const username = decoded.substring(0, colonIndex);
55
+ const hmac = decoded.substring(colonIndex + 1);
56
+
57
+ const expectedUsername = getEnvVariable('EDITOR_ADMIN');
58
+ const secret = getEnvVariable('EDITOR_PASSWORD');
59
+ const expectedHmac = createHmac('sha256', secret).update(username).digest('hex');
60
+
61
+ const usernameOk = timingSafeStringEqual(username, expectedUsername);
62
+ const hmacOk = timingSafeStringEqual(hmac, expectedHmac);
63
+
64
+ return usernameOk && hmacOk;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { LoginRateLimiter, MAX_ATTEMPTS, WINDOW_MS } from './loginRateLimiter.js';
3
+
4
+ describe('LoginRateLimiter', () => {
5
+ let limiter: LoginRateLimiter;
6
+
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ limiter = new LoginRateLimiter();
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ it('allows requests with zero attempts', () => {
17
+ const result = limiter.check('192.168.1.1');
18
+ expect(result.limited).toBe(false);
19
+ expect(result.retryAfter).toBe(0);
20
+ });
21
+
22
+ it('allows requests under the limit', () => {
23
+ for (let i = 0; i < MAX_ATTEMPTS - 1; i++) {
24
+ limiter.record('192.168.1.1');
25
+ }
26
+ const result = limiter.check('192.168.1.1');
27
+ expect(result.limited).toBe(false);
28
+ expect(result.retryAfter).toBe(0);
29
+ });
30
+
31
+ it('blocks requests at the limit', () => {
32
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
33
+ limiter.record('192.168.1.1');
34
+ }
35
+ const result = limiter.check('192.168.1.1');
36
+ expect(result.limited).toBe(true);
37
+ expect(result.retryAfter).toBeGreaterThan(0);
38
+ });
39
+
40
+ it('returns retryAfter as a positive integer', () => {
41
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
42
+ limiter.record('192.168.1.1');
43
+ }
44
+ const result = limiter.check('192.168.1.1');
45
+ expect(result.retryAfter).toBe(Math.ceil(WINDOW_MS / 1000));
46
+ });
47
+
48
+ it('tracks different IPs independently', () => {
49
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
50
+ limiter.record('192.168.1.1');
51
+ }
52
+ const blocked = limiter.check('192.168.1.1');
53
+ const allowed = limiter.check('192.168.1.2');
54
+ expect(blocked.limited).toBe(true);
55
+ expect(allowed.limited).toBe(false);
56
+ });
57
+
58
+ it('clears attempts on successful login', () => {
59
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
60
+ limiter.record('192.168.1.1');
61
+ }
62
+ expect(limiter.check('192.168.1.1').limited).toBe(true);
63
+
64
+ limiter.clear('192.168.1.1');
65
+ expect(limiter.check('192.168.1.1').limited).toBe(false);
66
+ });
67
+
68
+ it('does not count expired attempts', () => {
69
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
70
+ limiter.record('192.168.1.1');
71
+ }
72
+ expect(limiter.check('192.168.1.1').limited).toBe(true);
73
+
74
+ vi.advanceTimersByTime(WINDOW_MS + 1);
75
+
76
+ expect(limiter.check('192.168.1.1').limited).toBe(false);
77
+ });
78
+
79
+ it('records increment attempt count', () => {
80
+ limiter.record('192.168.1.1');
81
+ limiter.record('192.168.1.1');
82
+ limiter.record('192.168.1.1');
83
+
84
+ // 3 attempts — still under limit
85
+ expect(limiter.check('192.168.1.1').limited).toBe(false);
86
+
87
+ limiter.record('192.168.1.1');
88
+ limiter.record('192.168.1.1');
89
+
90
+ // 5 attempts — at limit
91
+ expect(limiter.check('192.168.1.1').limited).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,51 @@
1
+ export const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
2
+ export const MAX_ATTEMPTS = 5;
3
+
4
+ export class LoginRateLimiter {
5
+ private store = new Map<string, number[]>();
6
+
7
+ constructor() {
8
+ const interval = setInterval(() => this.cleanup(), WINDOW_MS);
9
+ if (typeof interval === 'object' && 'unref' in interval) {
10
+ (interval as NodeJS.Timeout).unref();
11
+ }
12
+ }
13
+
14
+ check(ip: string): { limited: boolean; retryAfter: number } {
15
+ const now = Date.now();
16
+ const windowStart = now - WINDOW_MS;
17
+ const attempts = (this.store.get(ip) ?? []).filter((t) => t > windowStart);
18
+
19
+ if (attempts.length >= MAX_ATTEMPTS) {
20
+ const oldest = attempts[0] ?? now;
21
+ const retryAfter = Math.ceil((oldest + WINDOW_MS - now) / 1000);
22
+ return { limited: true, retryAfter };
23
+ }
24
+
25
+ return { limited: false, retryAfter: 0 };
26
+ }
27
+
28
+ record(ip: string): void {
29
+ const attempts = this.store.get(ip) ?? [];
30
+ attempts.push(Date.now());
31
+ this.store.set(ip, attempts);
32
+ }
33
+
34
+ clear(ip: string): void {
35
+ this.store.delete(ip);
36
+ }
37
+
38
+ private cleanup(): void {
39
+ const windowStart = Date.now() - WINDOW_MS;
40
+ for (const [ip, attempts] of this.store) {
41
+ const fresh = attempts.filter((t) => t > windowStart);
42
+ if (fresh.length === 0) {
43
+ this.store.delete(ip);
44
+ } else {
45
+ this.store.set(ip, fresh);
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ export const loginRateLimiter = new LoginRateLimiter();
package/update.md CHANGED
@@ -1,3 +1,18 @@
1
+ # v1.0.15
2
+
3
+ ## Security: authentication hardening
4
+ - Replaced base64-encoded password cookie with opaque server-side session tokens (`crypto.randomUUID()`)
5
+ - New `Sessions` Astro DB table for server-side session storage with 24h expiry
6
+ - Timing-safe credential verification via `crypto.timingSafeEqual` — prevents timing attacks
7
+ - HMAC-based cookie tokens — password never leaves the server
8
+ - Rate limiting on login: 5 attempts per 15 minutes per IP, returns 429 with Retry-After header
9
+ - Failed login attempts logged with IP and timestamp
10
+ - Session invalidation on logout (server-side row deleted)
11
+ - Expired sessions purged on each login
12
+ - 31 new tests (207 total, up from 176)
13
+
14
+ ---
15
+
1
16
  # v1.0.14
2
17
 
3
18
  ## Bugfix: Regenerate text works without content-ai settings