HonzysClawdbot Nyk commited on
Commit
c9c4e2a
·
unverified ·
1 Parent(s): c4d8984

fix(session-cookie): migrate to __Host- prefix for secure contexts (#294)

Browse files

* fix(session-cookie): migrate to __Host- prefix for secure contexts

- Update session-cookie.ts to use __Host-mc-session for HTTPS requests
- Add LEGACY_MC_SESSION_COOKIE_NAME for backward compatibility with HTTP
- Add parseMcSessionCookieHeader() to parse both cookie names
- Add isRequestSecure() helper to detect HTTPS requests
- Update cookie options to enforce Secure, HttpOnly, SameSite=Strict
- Update all call sites (login, logout, google, me, proxy, auth)
- Update e2e tests to support both cookie names
- Update documentation (README.md, SKILL.md, openapi.json)

This addresses the high-priority TODO about migrating to the __Host- prefix
for enhanced security. The __Host- prefix enforces Secure + Path=/ and
prevents subdomain attacks. Legacy mc-session is still supported for HTTP
contexts.

* fix(tests): keep login-flow cookie name aligned with response

- remove unreachable nullish expression in session cookie secure flag

- use returned cookie pair in login-flow spec instead of forcing __Host- prefix

---------

Co-authored-by: Nyk <0xnykcd@googlemail.com>

README.md CHANGED
@@ -308,7 +308,7 @@ Three auth methods, three roles:
308
 
309
  | Method | Details |
310
  |--------|----------|
311
- | Session cookie | `POST /api/auth/login` sets `mc-session` (7-day expiry) |
312
  | API key | `x-api-key` header matches `API_KEY` env var |
313
  | Google Sign-In | OAuth with admin approval workflow |
314
 
 
308
 
309
  | Method | Details |
310
  |--------|----------|
311
+ | Session cookie | `POST /api/auth/login` sets `__Host-mc-session` (7-day expiry) for HTTPS, `mc-session` for HTTP |
312
  | API key | `x-api-key` header matches `API_KEY` env var |
313
  | Google Sign-In | OAuth with admin approval workflow |
314
 
SKILL.md CHANGED
@@ -44,7 +44,7 @@ MC supports two auth methods:
44
  | Method | Header | Use Case |
45
  |--------|--------|----------|
46
  | API Key | `x-api-key: <key>` or `Authorization: Bearer <key>` | Agents, scripts, CI/CD |
47
- | Session cookie | `Cookie: mc-session=<token>` | Browser UI |
48
 
49
  **Roles (hierarchical):** `viewer` < `operator` < `admin`
50
 
 
44
  | Method | Header | Use Case |
45
  |--------|--------|----------|
46
  | API Key | `x-api-key: <key>` or `Authorization: Bearer <key>` | Agents, scripts, CI/CD |
47
+ | Session cookie | `Cookie: __Host-mc-session=<token>` (HTTPS) or `mc-session=<token>` (HTTP) | Browser UI |
48
 
49
  **Roles (hierarchical):** `viewer` < `operator` < `admin`
50
 
openapi.json CHANGED
@@ -1757,7 +1757,7 @@
1757
  },
1758
  "responses": {
1759
  "200": {
1760
- "description": "Login successful. Sets mc-session cookie.",
1761
  "content": {
1762
  "application/json": {
1763
  "schema": {
@@ -1775,7 +1775,7 @@
1775
  "schema": {
1776
  "type": "string"
1777
  },
1778
- "description": "mc-session cookie"
1779
  }
1780
  }
1781
  },
@@ -7345,7 +7345,7 @@
7345
  "sessionCookie": {
7346
  "type": "apiKey",
7347
  "in": "cookie",
7348
- "name": "mc-session"
7349
  },
7350
  "apiKey": {
7351
  "type": "apiKey",
 
1757
  },
1758
  "responses": {
1759
  "200": {
1760
+ "description": "Login successful. Sets __Host-mc-session cookie (HTTPS) or mc-session (HTTP).",
1761
  "content": {
1762
  "application/json": {
1763
  "schema": {
 
1775
  "schema": {
1776
  "type": "string"
1777
  },
1778
+ "description": "__Host-mc-session cookie (secure HTTPS) or mc-session (HTTP legacy)"
1779
  }
1780
  }
1781
  },
 
7345
  "sessionCookie": {
7346
  "type": "apiKey",
7347
  "in": "cookie",
7348
+ "name": "__Host-mc-session"
7349
  },
7350
  "apiKey": {
7351
  "type": "apiKey",
src/app/api/auth/google/route.ts CHANGED
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
3
  import { createSession } from '@/lib/auth'
4
  import { getDatabase, logAuditEvent } from '@/lib/db'
5
  import { verifyGoogleIdToken } from '@/lib/google-auth'
6
- import { getMcSessionCookieOptions } from '@/lib/session-cookie'
7
  import { loginLimiter } from '@/lib/rate-limit'
8
 
9
  function upsertAccessRequest(input: {
@@ -100,10 +100,10 @@ export async function POST(request: NextRequest) {
100
  },
101
  })
102
 
103
- const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
104
- || new URL(request.url).protocol === 'https:'
105
 
106
- response.cookies.set('mc-session', token, {
107
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
108
  })
109
 
 
3
  import { createSession } from '@/lib/auth'
4
  import { getDatabase, logAuditEvent } from '@/lib/db'
5
  import { verifyGoogleIdToken } from '@/lib/google-auth'
6
+ import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
7
  import { loginLimiter } from '@/lib/rate-limit'
8
 
9
  function upsertAccessRequest(input: {
 
100
  },
101
  })
102
 
103
+ const isSecureRequest = isRequestSecure(request)
104
+ const cookieName = getMcSessionCookieName(isSecureRequest)
105
 
106
+ response.cookies.set(cookieName, token, {
107
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
108
  })
109
 
src/app/api/auth/login/route.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { NextResponse } from 'next/server'
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
- import { getMcSessionCookieOptions } from '@/lib/session-cookie'
5
  import { loginLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
7
 
@@ -43,10 +43,10 @@ export async function POST(request: Request) {
43
  },
44
  })
45
 
46
- const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
47
- || new URL(request.url).protocol === 'https:'
48
 
49
- response.cookies.set('mc-session', token, {
50
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
51
  })
52
 
 
1
  import { NextResponse } from 'next/server'
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
+ import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
5
  import { loginLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
7
 
 
43
  },
44
  })
45
 
46
+ const isSecureRequest = isRequestSecure(request)
47
+ const cookieName = getMcSessionCookieName(isSecureRequest)
48
 
49
+ response.cookies.set(cookieName, token, {
50
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
51
  })
52
 
src/app/api/auth/logout/route.ts CHANGED
@@ -1,13 +1,12 @@
1
  import { NextResponse } from 'next/server'
2
  import { destroySession, getUserFromRequest } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
- import { getMcSessionCookieOptions } from '@/lib/session-cookie'
5
 
6
  export async function POST(request: Request) {
7
  const user = getUserFromRequest(request)
8
  const cookieHeader = request.headers.get('cookie') || ''
9
- const match = cookieHeader.match(/(?:^|;\s*)mc-session=([^;]*)/)
10
- const token = match ? decodeURIComponent(match[1]) : null
11
 
12
  if (token) {
13
  destroySession(token)
@@ -19,8 +18,10 @@ export async function POST(request: Request) {
19
  }
20
 
21
  const response = NextResponse.json({ ok: true })
22
- response.cookies.set('mc-session', '', {
23
- ...getMcSessionCookieOptions({ maxAgeSeconds: 0 }),
 
 
24
  })
25
 
26
  return response
 
1
  import { NextResponse } from 'next/server'
2
  import { destroySession, getUserFromRequest } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
+ import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure, parseMcSessionCookieHeader } from '@/lib/session-cookie'
5
 
6
  export async function POST(request: Request) {
7
  const user = getUserFromRequest(request)
8
  const cookieHeader = request.headers.get('cookie') || ''
9
+ const token = parseMcSessionCookieHeader(cookieHeader)
 
10
 
11
  if (token) {
12
  destroySession(token)
 
18
  }
19
 
20
  const response = NextResponse.json({ ok: true })
21
+ const isSecureRequest = isRequestSecure(request)
22
+ const cookieName = getMcSessionCookieName(isSecureRequest)
23
+ response.cookies.set(cookieName, '', {
24
+ ...getMcSessionCookieOptions({ maxAgeSeconds: 0, isSecureRequest }),
25
  })
26
 
27
  return response
src/app/api/auth/me/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getUserFromRequest, updateUser, requireRole, destroyAllUserSessions, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
5
- import { getMcSessionCookieOptions } from '@/lib/session-cookie'
6
  import { logger } from '@/lib/logger'
7
 
8
  export async function GET(request: Request) {
@@ -117,9 +117,9 @@ export async function PATCH(request: NextRequest) {
117
  // Issue a fresh session cookie after password change (old ones were just revoked)
118
  if (updates.password) {
119
  const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id ?? 1)
120
- const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https'
121
- || new URL(request.url).protocol === 'https:'
122
- response.cookies.set('mc-session', token, {
123
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
124
  })
125
  }
 
2
  import { getUserFromRequest, updateUser, requireRole, destroyAllUserSessions, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
5
+ import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
6
  import { logger } from '@/lib/logger'
7
 
8
  export async function GET(request: Request) {
 
117
  // Issue a fresh session cookie after password change (old ones were just revoked)
118
  if (updates.password) {
119
  const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id ?? 1)
120
+ const isSecureRequest = isRequestSecure(request)
121
+ const cookieName = getMcSessionCookieName(isSecureRequest)
122
+ response.cookies.set(cookieName, token, {
123
  ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
124
  })
125
  }
src/lib/auth.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto'
2
  import { getDatabase } from './db'
3
  import { hashPassword, verifyPassword } from './password'
4
  import { logSecurityEvent } from './security-events'
 
5
 
6
  // Plugin hook: extensions can register a custom API key resolver without modifying this file.
7
  type AuthResolverHook = (apiKey: string, agentName: string | null) => User | null
@@ -346,7 +347,7 @@ export function getUserFromRequest(request: Request): User | null {
346
 
347
  // Check session cookie
348
  const cookieHeader = request.headers.get('cookie') || ''
349
- const sessionToken = parseCookie(cookieHeader, 'mc-session')
350
  if (sessionToken) {
351
  const user = validateSession(sessionToken)
352
  if (user) return { ...user, agent_name: agentName }
@@ -510,7 +511,3 @@ export function requireRole(
510
  return { user }
511
  }
512
 
513
- function parseCookie(cookieHeader: string, name: string): string | null {
514
- const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
515
- return match ? decodeURIComponent(match[1]) : null
516
- }
 
2
  import { getDatabase } from './db'
3
  import { hashPassword, verifyPassword } from './password'
4
  import { logSecurityEvent } from './security-events'
5
+ import { parseMcSessionCookieHeader } from './session-cookie'
6
 
7
  // Plugin hook: extensions can register a custom API key resolver without modifying this file.
8
  type AuthResolverHook = (apiKey: string, agentName: string | null) => User | null
 
347
 
348
  // Check session cookie
349
  const cookieHeader = request.headers.get('cookie') || ''
350
+ const sessionToken = parseMcSessionCookieHeader(cookieHeader)
351
  if (sessionToken) {
352
  const user = validateSession(sessionToken)
353
  if (user) return { ...user, agent_name: agentName }
 
511
  return { user }
512
  }
513
 
 
 
 
 
src/lib/session-cookie.ts CHANGED
@@ -1,17 +1,27 @@
1
  import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'
2
 
3
- // TODO: Migrate cookie name to use __Host- prefix for secure contexts.
4
- // The __Host- prefix enforces Secure + Path=/ and prevents subdomain attacks.
5
- // Migration path: add MC_SESSION_COOKIE_NAME usage to all callers
6
- // (proxy.ts, auth/login, auth/logout, auth/google, lib/auth.ts, tests)
7
- // then switch the default to use __Host- prefix when secure=true.
8
- export const MC_SESSION_COOKIE_NAME = 'mc-session'
9
 
10
- export function getMcSessionCookieName(secure: boolean): string {
11
- // TODO: Enable __Host- prefix once all callers use this function.
12
- // When enabled: return secure ? '__Host-mc-session' : 'mc-session'
13
- void secure
14
- return MC_SESSION_COOKIE_NAME
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
16
 
17
  function envFlag(name: string): boolean | undefined {
@@ -25,23 +35,13 @@ function envFlag(name: string): boolean | undefined {
25
 
26
  export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial<ResponseCookie> {
27
  const secureEnv = envFlag('MC_COOKIE_SECURE')
28
- // Explicit env wins. Otherwise auto-detect: only set secure if request came over HTTPS.
29
- // Falls back to NODE_ENV=production when no request hint is available.
30
  const secure = secureEnv ?? input.isSecureRequest ?? process.env.NODE_ENV === 'production'
31
 
32
- // Strict is safest for this app (same-site UI + API), but allow override for edge cases.
33
- const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase()
34
- const sameSite: ResponseCookie['sameSite'] =
35
- sameSiteRaw === 'lax' ? 'lax' :
36
- sameSiteRaw === 'none' ? 'none' :
37
- 'strict'
38
-
39
  return {
40
  httpOnly: true,
41
  secure,
42
- sameSite,
43
  maxAge: input.maxAgeSeconds,
44
  path: '/',
45
  }
46
  }
47
-
 
1
  import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'
2
 
3
+ export const MC_SESSION_COOKIE_NAME = '__Host-mc-session'
4
+ export const LEGACY_MC_SESSION_COOKIE_NAME = 'mc-session'
5
+ const MC_SESSION_COOKIE_NAMES = [MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME] as const
 
 
 
6
 
7
+ export function getMcSessionCookieName(isSecureRequest: boolean): string {
8
+ return isSecureRequest ? MC_SESSION_COOKIE_NAME : LEGACY_MC_SESSION_COOKIE_NAME
9
+ }
10
+
11
+ export function isRequestSecure(request: Request): boolean {
12
+ return request.headers.get('x-forwarded-proto') === 'https'
13
+ || new URL(request.url).protocol === 'https:'
14
+ }
15
+
16
+ export function parseMcSessionCookieHeader(cookieHeader: string): string | null {
17
+ if (!cookieHeader) return null
18
+ for (const cookieName of MC_SESSION_COOKIE_NAMES) {
19
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]*)`))
20
+ if (match) {
21
+ return decodeURIComponent(match[1])
22
+ }
23
+ }
24
+ return null
25
  }
26
 
27
  function envFlag(name: string): boolean | undefined {
 
35
 
36
  export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial<ResponseCookie> {
37
  const secureEnv = envFlag('MC_COOKIE_SECURE')
 
 
38
  const secure = secureEnv ?? input.isSecureRequest ?? process.env.NODE_ENV === 'production'
39
 
 
 
 
 
 
 
 
40
  return {
41
  httpOnly: true,
42
  secure,
43
+ sameSite: 'strict',
44
  maxAge: input.maxAgeSeconds,
45
  path: '/',
46
  }
47
  }
 
src/proxy.ts CHANGED
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
2
  import os from 'node:os'
3
  import { NextResponse } from 'next/server'
4
  import type { NextRequest } from 'next/server'
 
5
 
6
  /** Constant-time string comparison using Node.js crypto. */
7
  function safeCompare(a: string, b: string): boolean {
@@ -189,7 +190,7 @@ export function proxy(request: NextRequest) {
189
  }
190
 
191
  // Check for session cookie
192
- const sessionToken = request.cookies.get('mc-session')?.value
193
 
194
  // API routes: accept session cookie OR API key
195
  if (pathname.startsWith('/api/')) {
 
2
  import os from 'node:os'
3
  import { NextResponse } from 'next/server'
4
  import type { NextRequest } from 'next/server'
5
+ import { MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME } from '@/lib/session-cookie'
6
 
7
  /** Constant-time string comparison using Node.js crypto. */
8
  function safeCompare(a: string, b: string): boolean {
 
190
  }
191
 
192
  // Check for session cookie
193
+ const sessionToken = request.cookies.get(MC_SESSION_COOKIE_NAME)?.value || request.cookies.get(LEGACY_MC_SESSION_COOKIE_NAME)?.value
194
 
195
  // API routes: accept session cookie OR API key
196
  if (pathname.startsWith('/api/')) {
tests/login-flow.spec.ts CHANGED
@@ -45,7 +45,7 @@ test.describe('Login Flow', () => {
45
 
46
  const cookies = res.headers()['set-cookie']
47
  expect(cookies).toBeDefined()
48
- expect(cookies).toContain('mc-session')
49
  })
50
 
51
  test('login API rejects wrong password', async ({ request }) => {
@@ -66,15 +66,16 @@ test.describe('Login Flow', () => {
66
 
67
  // Extract session cookie from Set-Cookie header
68
  const setCookie = loginRes.headers()['set-cookie'] || ''
69
- const match = setCookie.match(/mc-session=([^;]+)/)
70
  expect(match).toBeTruthy()
71
- const sessionToken = match![1]
72
 
73
- // Use the session cookie to access /api/auth/me
74
  const meRes = await request.get('/api/auth/me', {
75
- headers: { 'cookie': `mc-session=${sessionToken}`, 'x-forwarded-for': '10.88.88.2' }
76
  })
77
  expect(meRes.status()).toBe(200)
 
78
  const body = await meRes.json()
79
  expect(body.user?.username).toBe(TEST_USER)
80
  expect(typeof body.user?.workspace_id).toBe('number')
 
45
 
46
  const cookies = res.headers()['set-cookie']
47
  expect(cookies).toBeDefined()
48
+ expect(cookies).toMatch(/(__Host-)?mc-session/)
49
  })
50
 
51
  test('login API rejects wrong password', async ({ request }) => {
 
66
 
67
  // Extract session cookie from Set-Cookie header
68
  const setCookie = loginRes.headers()['set-cookie'] || ''
69
+ const match = setCookie.match(/(?:__Host-)?mc-session=([^;]+)/)
70
  expect(match).toBeTruthy()
71
+ const sessionCookiePair = match?.[0] || ''
72
 
73
+ // Use the same cookie name/value returned by login
74
  const meRes = await request.get('/api/auth/me', {
75
+ headers: { 'cookie': sessionCookiePair, 'x-forwarded-for': '10.88.88.2' }
76
  })
77
  expect(meRes.status()).toBe(200)
78
+
79
  const body = await meRes.json()
80
  expect(body.user?.username).toBe(TEST_USER)
81
  expect(typeof body.user?.workspace_id).toBe('number')