Skip to content

Commit 779053d

Browse files
committed
fix(admin): improve admin session cookie and response handling
Signed-off-by: ABHAY PANDEY <pandeyabhay967@gmail.com>
1 parent 92516e3 commit 779053d

9 files changed

Lines changed: 159 additions & 24 deletions

File tree

src/admin/password-admin-auth-provider.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,40 @@ import { Settings } from '../@types/settings'
55
import { adminLoginBodySchema } from '../schemas/admin-login-schema'
66
import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password'
77
import {
8+
buildAdminSessionCookieHeader,
89
createAdminSessionToken,
910
getAdminSessionTokenFromRequest,
1011
isValidAdminSessionToken,
1112
parseAdminSessionToken,
13+
resolveAdminSessionTtlSeconds,
1214
} from '../utils/admin-session'
1315
import { validateSchema } from '../utils/validation'
1416

15-
const DEFAULT_SESSION_TTL_SECONDS = 86400
16-
1717
export class PasswordAdminAuthProvider implements IAdminAuthProvider {
1818
public constructor(private readonly settings: () => Settings) {}
1919

2020
public async handleLogin(request: Request, response: Response): Promise<void> {
2121
const validation = validateSchema(adminLoginBodySchema)(request.body)
2222
if (validation.error) {
23-
response.status(400).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Invalid request' }))
23+
response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' })
2424
return
2525
}
2626

2727
if (!this.verifyPassword(validation.value.password)) {
28-
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
28+
response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' })
2929
return
3030
}
3131

3232
const currentSettings = this.settings()
33-
const ttl = currentSettings.admin?.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS
34-
const expiresAt = Math.floor(Date.now() / 1000) + ttl
33+
const sessionTtlSeconds = resolveAdminSessionTtlSeconds(currentSettings.admin?.sessionTtlSeconds)
34+
const expiresAt = Math.floor(Date.now() / 1000) + sessionTtlSeconds
3535
const token = createAdminSessionToken(expiresAt)
3636

3737
response
3838
.status(200)
3939
.setHeader('content-type', 'application/json')
40-
.setHeader('Set-Cookie', `admin_session=${token}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${ttl}`)
41-
.send(JSON.stringify({ authenticated: true, expiresAt }))
40+
.setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds))
41+
.send({ authenticated: true, expiresAt })
4242
}
4343

4444
public isRequestAuthenticated(request: Request): boolean {

src/controllers/admin/get-health-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ import { collectAdminHealthSnapshot } from '../../utils/admin-health'
66
export class GetAdminHealthController implements IController {
77
public async handleRequest(_request: Request, response: Response): Promise<void> {
88
const health = await collectAdminHealthSnapshot()
9-
response.status(200).setHeader('content-type', 'application/json').send(JSON.stringify(health))
9+
response.status(200).setHeader('content-type', 'application/json').send(health)
1010
}
1111
}

src/controllers/admin/get-session-controller.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,9 @@ export class GetAdminSessionController implements IController {
77
public constructor(private readonly authProvider: IAdminAuthProvider) {}
88

99
public async handleRequest(request: Request, response: Response): Promise<void> {
10-
if (!this.authProvider.isRequestAuthenticated(request)) {
11-
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
12-
return
13-
}
14-
15-
response.status(200).setHeader('content-type', 'application/json').send(
16-
JSON.stringify({
17-
authenticated: true,
18-
expiresAt: this.authProvider.getSessionExpiresAt(request),
19-
}),
20-
)
10+
response.status(200).setHeader('content-type', 'application/json').send({
11+
authenticated: true,
12+
expiresAt: this.authProvider.getSessionExpiresAt(request),
13+
})
2114
}
2215
}

src/handlers/request-handlers/admin-rate-limit-middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { rateLimiterFactory } from '../../factories/rate-limiter-factory'
55
import { isAdminRateLimited } from '../../utils/admin-rate-limit'
66

77
const sendTooManyRequests = (response: Response) => {
8-
response.status(429).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Too many requests' }))
8+
response.status(429).setHeader('content-type', 'application/json').send({ error: 'Too many requests' })
99
}
1010

1111
export const adminLoginRateLimitMiddleware = async (request: Request, response: Response, next: NextFunction) => {

src/routes/admin/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const requireAdminEnabled = (_request: Request, response: Response, next: NextFu
2626

2727
const requireAdminAuth = (request: Request, response: Response, next: NextFunction) => {
2828
if (!createAdminAuthProvider().isRequestAuthenticated(request)) {
29-
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
29+
response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' })
3030
return
3131
}
3232

src/utils/admin-session.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
import { timingSafeEqual } from 'crypto'
2+
import { IncomingMessage } from 'http'
23

4+
import { Settings } from '../@types/settings'
5+
import { getPublicPathPrefix, isSecureRequest, joinPathPrefix } from './http'
36
import { deriveFromSecret, hmacSha256 } from './secret'
47

8+
export const DEFAULT_ADMIN_SESSION_TTL_SECONDS = 86400
9+
10+
export const resolveAdminSessionTtlSeconds = (
11+
sessionTtlSeconds: number | undefined,
12+
defaultTtlSeconds = DEFAULT_ADMIN_SESSION_TTL_SECONDS,
13+
): number => {
14+
if (typeof sessionTtlSeconds === 'number' && Number.isFinite(sessionTtlSeconds) && sessionTtlSeconds > 0) {
15+
return Math.floor(sessionTtlSeconds)
16+
}
17+
18+
return defaultTtlSeconds
19+
}
20+
21+
export const buildAdminSessionCookieHeader = (
22+
request: IncomingMessage,
23+
settings: Settings,
24+
token: string,
25+
maxAgeSeconds: number,
26+
): string => {
27+
const cookiePath = joinPathPrefix(getPublicPathPrefix(request, settings), '/admin')
28+
const secure = isSecureRequest(request, settings) ? '; Secure' : ''
29+
30+
return `admin_session=${token}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}${secure}`
31+
}
32+
533
export const createAdminSessionToken = (expiresAt: number): string => {
634
const signature = hmacSha256(deriveFromSecret('admin-session'), `${expiresAt}`).toString('hex')
735
return `${expiresAt}.${signature}`

src/utils/http.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ export const getPublicPathPrefix = (request: IncomingMessage, settings: Settings
111111
return getTrustedForwardedPathPrefix(request, settings) || getRelayUrlPathPrefix(settings.info?.relay_url)
112112
}
113113

114+
export const isSecureRequest = (request: IncomingMessage, settings: Settings): boolean => {
115+
if ('secure' in request && (request as { secure?: boolean }).secure === true) {
116+
return true
117+
}
118+
119+
const socketAddress = request.socket?.remoteAddress
120+
if (typeof socketAddress !== 'string' || !isTrustedProxy(socketAddress, settings)) {
121+
return false
122+
}
123+
124+
const rawHeader = request.headers?.['x-forwarded-proto']
125+
const rawProto = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader
126+
const proto = typeof rawProto === 'string' ? rawProto.split(',')[0].trim().toLowerCase() : ''
127+
128+
return proto === 'https'
129+
}
130+
114131
export const joinPathPrefix = (prefix: string, path: string): string => {
115132
const normalizedPrefix = prefix.replace(/\/+$/, '')
116133
const normalizedPath = path.startsWith('/') ? path : `/${path}`

test/unit/utils/admin-session.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { expect } from 'chai'
22

3-
import { createAdminSessionToken, getAdminSessionTokenFromRequest, isValidAdminSessionToken } from '../../../src/utils/admin-session'
3+
import {
4+
buildAdminSessionCookieHeader,
5+
createAdminSessionToken,
6+
getAdminSessionTokenFromRequest,
7+
isValidAdminSessionToken,
8+
resolveAdminSessionTtlSeconds,
9+
} from '../../../src/utils/admin-session'
410

511
describe('admin-session', () => {
612
const originalSecret = process.env.SECRET
@@ -29,4 +35,48 @@ describe('admin-session', () => {
2935
expect(getAdminSessionTokenFromRequest('Bearer abc.def', undefined)).to.equal('abc.def')
3036
expect(getAdminSessionTokenFromRequest(undefined, 'admin_session=abc.def; other=value')).to.equal('abc.def')
3137
})
38+
39+
it('falls back to the default ttl for invalid sessionTtlSeconds values', () => {
40+
expect(resolveAdminSessionTtlSeconds(undefined)).to.equal(86400)
41+
expect(resolveAdminSessionTtlSeconds(0)).to.equal(86400)
42+
expect(resolveAdminSessionTtlSeconds(-1)).to.equal(86400)
43+
expect(resolveAdminSessionTtlSeconds(Number.NaN)).to.equal(86400)
44+
expect(resolveAdminSessionTtlSeconds(3600)).to.equal(3600)
45+
})
46+
47+
it('builds a secure admin session cookie behind a trusted https proxy', () => {
48+
const cookie = buildAdminSessionCookieHeader(
49+
{
50+
headers: { 'x-forwarded-proto': 'https', 'x-forwarded-prefix': '/nostream' },
51+
socket: { remoteAddress: '127.0.0.1' },
52+
} as any,
53+
{
54+
info: { relay_url: 'wss://relay.example.com/nostream' },
55+
network: { trustedProxies: ['127.0.0.1'] },
56+
} as any,
57+
'token-value',
58+
3600,
59+
)
60+
61+
expect(cookie).to.equal(
62+
'admin_session=token-value; Path=/nostream/admin; HttpOnly; SameSite=Strict; Max-Age=3600; Secure',
63+
)
64+
})
65+
66+
it('omits Secure for local http requests', () => {
67+
const cookie = buildAdminSessionCookieHeader(
68+
{
69+
headers: {},
70+
socket: { remoteAddress: '127.0.0.1' },
71+
} as any,
72+
{
73+
info: { relay_url: 'ws://localhost:8008' },
74+
network: {},
75+
} as any,
76+
'token-value',
77+
3600,
78+
)
79+
80+
expect(cookie).to.equal('admin_session=token-value; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=3600')
81+
})
3282
})

test/unit/utils/http.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect } from 'chai'
22
import { IncomingMessage } from 'http'
33

4-
import { getPublicPathPrefix, getRemoteAddress, joinPathPrefix } from '../../../src/utils/http'
4+
import { getPublicPathPrefix, getRemoteAddress, isSecureRequest, joinPathPrefix } from '../../../src/utils/http'
55

66
describe('getRemoteAddress', () => {
77
const header = 'x-forwarded-for'
@@ -169,3 +169,50 @@ describe('joinPathPrefix', () => {
169169
expect(joinPathPrefix('/nostream', '/invoices')).to.equal('/nostream/invoices')
170170
})
171171
})
172+
173+
describe('isSecureRequest', () => {
174+
const settings = {
175+
info: { relay_url: 'wss://relay.example.com' },
176+
network: { trustedProxies: ['127.0.0.1'] },
177+
} as any
178+
179+
it('returns true when express marks the request secure', () => {
180+
expect(isSecureRequest({ secure: true, socket: { remoteAddress: 'client' } } as any, settings)).to.equal(true)
181+
})
182+
183+
it('returns true for trusted x-forwarded-proto https', () => {
184+
expect(
185+
isSecureRequest(
186+
{
187+
headers: { 'x-forwarded-proto': 'https' },
188+
socket: { remoteAddress: '127.0.0.1' },
189+
} as any,
190+
settings,
191+
),
192+
).to.equal(true)
193+
})
194+
195+
it('returns false for untrusted x-forwarded-proto https', () => {
196+
expect(
197+
isSecureRequest(
198+
{
199+
headers: { 'x-forwarded-proto': 'https' },
200+
socket: { remoteAddress: 'client' },
201+
} as any,
202+
settings,
203+
),
204+
).to.equal(false)
205+
})
206+
207+
it('returns false for trusted http x-forwarded-proto', () => {
208+
expect(
209+
isSecureRequest(
210+
{
211+
headers: { 'x-forwarded-proto': 'http' },
212+
socket: { remoteAddress: '127.0.0.1' },
213+
} as any,
214+
settings,
215+
),
216+
).to.equal(false)
217+
})
218+
})

0 commit comments

Comments
 (0)