Skip to content
Open
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
78 changes: 78 additions & 0 deletions __tests__/server/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { extractQbtSessionToken } from '../../src/server/routes/proxy'

describe('extractQbtSessionToken', () => {
describe('qBittorrent cookie formats', () => {
it('extracts value from legacy SID cookie (qBittorrent < 5.2)', () => {
expect(extractQbtSessionToken('SID=abc123')).toBe('abc123')
})

it('extracts value from QBT_SID_8081 cookie (qBittorrent 5.2.x, port 8081)', () => {
expect(extractQbtSessionToken('QBT_SID_8081=abc123')).toBe('abc123')
})

it('extracts value from QBT_SID_443 cookie (qBittorrent 5.2.x, port 443)', () => {
expect(extractQbtSessionToken('QBT_SID_443=abc123')).toBe('abc123')
})

it('extracts value from arbitrary high port QBT_SID cookie', () => {
expect(extractQbtSessionToken('QBT_SID_12345=abc123')).toBe('abc123')
})
})

describe('full Set-Cookie value', () => {
it('strips attributes after the first semicolon', () => {
expect(extractQbtSessionToken('QBT_SID_8081=abc123; Path=/; HttpOnly')).toBe('abc123')
})

it('strips attributes for legacy SID cookie', () => {
expect(extractQbtSessionToken('SID=abc123; Path=/; HttpOnly')).toBe('abc123')
})
})

describe('cookie name validation', () => {
it('rejects a cookie whose name does not contain SID', () => {
expect(extractQbtSessionToken('theme=dark')).toBe('')
})

it('picks the SID cookie out of multiple cookies', () => {
expect(extractQbtSessionToken('theme=dark; SID=abc123; lang=en')).toBe('abc123')
})

it('picks the QBT_SID cookie out of multiple cookies', () => {
expect(extractQbtSessionToken('theme=dark; QBT_SID_8081=abc123')).toBe('abc123')
})

it('does not match lowercase sid in another cookie name', () => {
expect(extractQbtSessionToken('resident=user; theme=dark')).toBe('')
})
})

describe('value edge cases', () => {
it('preserves equals signs inside the session value', () => {
expect(extractQbtSessionToken('SID=abc==def')).toBe('abc==def')
})

it('handles values that look like base64 padding', () => {
expect(extractQbtSessionToken('QBT_SID_8081=YWJj==')).toBe('YWJj==')
})
})

describe('invalid input', () => {
it('returns empty string for null', () => {
expect(extractQbtSessionToken(null)).toBe('')
})

it('returns empty string for undefined', () => {
expect(extractQbtSessionToken(undefined)).toBe('')
})

it('returns empty string for empty string', () => {
expect(extractQbtSessionToken('')).toBe('')
})

it('returns empty string when no equals sign is present', () => {
expect(extractQbtSessionToken('malformed-cookie')).toBe('')
})
})
})
19 changes: 18 additions & 1 deletion src/server/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ function getAgentUrl(instance: Instance): string {
return url.origin
}

export function extractQbtSessionToken(cookie: string | null | undefined): string {
if (!cookie) return ''

const sessionCookie = cookie
.split(';')
.map((part) => part.trim())
.find((part) => {
const equalsIndex = part.indexOf('=')
return equalsIndex > 0 && part.slice(0, equalsIndex).includes('SID')
})

const equalsIndex = sessionCookie?.indexOf('=') ?? -1
if (equalsIndex < 0) return ''

return sessionCookie!.slice(equalsIndex + 1)
}

proxy.all('/:id/agent/*', async (c) => {
const user = c.get('user')
const instanceId = Number(c.req.param('id'))
Expand Down Expand Up @@ -170,7 +187,7 @@ proxy.all('/:id/agent/*', async (c) => {

try {
const cookie = await getQbtSession(instance)
const sid = cookie?.match(/SID=([^;]+)/)?.[1] || ''
const sid = extractQbtSessionToken(cookie)

const headers = new Headers()
headers.set('X-QBT-SID', sid)
Expand Down