-
Notifications
You must be signed in to change notification settings - Fork 475
Base clientSecretAuth on the verification result #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| /** | ||
| * Tests for validateSdkRequest — the ingestion auth check behind POST /track | ||
| * and the deprecated POST /event. | ||
| * | ||
| * The behaviour guarded here: `req.clientSecretAuth` and revenue ingestion | ||
| * follow whether the supplied secret verified against the stored hash, not | ||
| * whether a secret string was present on the request. | ||
| */ | ||
|
|
||
| import type { FastifyRequest } from 'fastify'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const verifyPassword = vi.fn(); | ||
| const getClientByIdCached = vi.fn(); | ||
| const redisGet = vi.fn(); | ||
| const redisSetex = vi.fn(); | ||
|
|
||
| vi.mock('@openpanel/common/server', () => ({ | ||
| verifyPassword: (...args: unknown[]) => verifyPassword(...args), | ||
| })); | ||
| vi.mock('@openpanel/db', () => ({ | ||
| ClientType: { read: 'read', write: 'write', root: 'root' }, | ||
| getClientByIdCached: (...args: unknown[]) => getClientByIdCached(...args), | ||
| })); | ||
| vi.mock('@openpanel/redis', () => ({ | ||
| getRedisCache: () => ({ get: redisGet, setex: redisSetex }), | ||
| })); | ||
|
|
||
| const { validateSdkRequest } = await import('./auth'); | ||
|
|
||
| const CLIENT_ID = '11111111-1111-4111-8111-111111111111'; | ||
| const ORIGIN = 'https://app.example.com'; | ||
|
|
||
| function makeClient(overrides: Record<string, unknown> = {}) { | ||
| return { | ||
| id: CLIENT_ID, | ||
| projectId: 'proj-1', | ||
| secret: 'stored-hash', | ||
| ignoreCorsAndSecret: false, | ||
| ...overrides, | ||
| project: { | ||
| cors: [ORIGIN], | ||
| allowUnsafeRevenueTracking: false, | ||
| filters: [], | ||
| ...((overrides.project as Record<string, unknown>) ?? {}), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function makeReq({ | ||
| headers = {}, | ||
| revenue = false, | ||
| }: { | ||
| headers?: Record<string, string>; | ||
| revenue?: boolean; | ||
| } = {}) { | ||
| return { | ||
| headers: { 'openpanel-client-id': CLIENT_ID, ...headers }, | ||
| clientIp: '1.2.3.4', | ||
| body: { | ||
| type: 'track', | ||
| payload: { | ||
| name: 'purchase', | ||
| properties: revenue ? { __revenue: 42 } : {}, | ||
| }, | ||
| }, | ||
| } as unknown as FastifyRequest<never> & { clientSecretAuth?: boolean }; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| verifyPassword.mockReset(); | ||
| getClientByIdCached.mockReset(); | ||
| redisGet.mockReset(); | ||
| redisSetex.mockReset(); | ||
| redisGet.mockResolvedValue(null); | ||
| redisSetex.mockResolvedValue('OK'); | ||
| getClientByIdCached.mockResolvedValue(makeClient()); | ||
| }); | ||
|
|
||
| describe('validateSdkRequest', () => { | ||
| it('does not mark a request authenticated when the secret does not match', async () => { | ||
| verifyPassword.mockResolvedValue(false); | ||
| const req = makeReq({ | ||
| headers: { origin: ORIGIN, 'openpanel-client-secret': 'guessed' }, | ||
| }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).resolves.toMatchObject({ | ||
| id: CLIENT_ID, | ||
| }); | ||
| expect(req.clientSecretAuth).toBe(false); | ||
| expect(redisSetex).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects revenue from an origin-authorized request with a bad secret', async () => { | ||
| verifyPassword.mockResolvedValue(false); | ||
| const req = makeReq({ | ||
| headers: { origin: ORIGIN, 'openpanel-client-secret': 'guessed' }, | ||
| revenue: true, | ||
| }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).rejects.toThrow( | ||
| 'Revenue tracking is not allowed without a client secret' | ||
| ); | ||
| expect(req.clientSecretAuth).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects a bad secret outright when no origin is allowed', async () => { | ||
| verifyPassword.mockResolvedValue(false); | ||
| const req = makeReq({ | ||
| headers: { 'openpanel-client-secret': 'guessed' }, | ||
| }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).rejects.toThrow( | ||
| 'Invalid cors or secret' | ||
| ); | ||
| expect(req.clientSecretAuth).toBe(false); | ||
| }); | ||
|
|
||
| it('lets ordinary browser traffic through on the origin alone', async () => { | ||
| const req = makeReq({ headers: { origin: ORIGIN } }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).resolves.toMatchObject({ | ||
| id: CLIENT_ID, | ||
| }); | ||
| expect(req.clientSecretAuth).toBe(false); | ||
| expect(verifyPassword).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('authorizes a correct secret without an origin and accepts revenue', async () => { | ||
| verifyPassword.mockResolvedValue(true); | ||
| const req = makeReq({ | ||
| headers: { 'openpanel-client-secret': 'correct' }, | ||
| revenue: true, | ||
| }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).resolves.toMatchObject({ | ||
| id: CLIENT_ID, | ||
| }); | ||
| expect(req.clientSecretAuth).toBe(true); | ||
| expect(redisSetex).toHaveBeenCalledWith( | ||
| expect.stringContaining(`client:auth:${CLIENT_ID}:`), | ||
| 300, | ||
| 'true' | ||
| ); | ||
| }); | ||
|
|
||
| it('trusts a cached successful verification without re-hashing', async () => { | ||
| redisGet.mockResolvedValue('true'); | ||
| const req = makeReq({ headers: { 'openpanel-client-secret': 'correct' } }); | ||
|
|
||
| await validateSdkRequest(req as never); | ||
|
|
||
| expect(req.clientSecretAuth).toBe(true); | ||
| expect(verifyPassword).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not trust a cached "false" left over from earlier releases', async () => { | ||
| redisGet.mockResolvedValue('false'); | ||
| verifyPassword.mockResolvedValue(false); | ||
| const req = makeReq({ | ||
| headers: { origin: ORIGIN, 'openpanel-client-secret': 'guessed' }, | ||
| }); | ||
|
|
||
| await validateSdkRequest(req as never); | ||
|
|
||
| expect(req.clientSecretAuth).toBe(false); | ||
| expect(verifyPassword).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('skips the cache entirely when the client has no stored secret', async () => { | ||
| getClientByIdCached.mockResolvedValue(makeClient({ secret: null })); | ||
| const req = makeReq({ | ||
| headers: { origin: ORIGIN, 'openpanel-client-secret': 'anything' }, | ||
| }); | ||
|
|
||
| await validateSdkRequest(req as never); | ||
|
|
||
| expect(req.clientSecretAuth).toBe(false); | ||
| expect(redisGet).not.toHaveBeenCalled(); | ||
| expect(redisSetex).not.toHaveBeenCalled(); | ||
| expect(verifyPassword).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('accepts revenue with no secret when allowUnsafeRevenueTracking is on', async () => { | ||
| getClientByIdCached.mockResolvedValue( | ||
| makeClient({ project: { allowUnsafeRevenueTracking: true } }) | ||
| ); | ||
| const req = makeReq({ headers: { origin: ORIGIN }, revenue: true }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).resolves.toMatchObject({ | ||
| id: CLIENT_ID, | ||
| }); | ||
| expect(req.clientSecretAuth).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects revenue with no secret when allowUnsafeRevenueTracking is off', async () => { | ||
| const req = makeReq({ headers: { origin: ORIGIN }, revenue: true }); | ||
|
|
||
| await expect(validateSdkRequest(req as never)).rejects.toThrow( | ||
| 'Revenue tracking is not allowed without a client secret' | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import { verifyPassword } from '@openpanel/common/server'; | ||
| import type { IServiceClientWithProject } from '@openpanel/db'; | ||
| import { ClientType, getClientByIdCached } from '@openpanel/db'; | ||
| import { getCache } from '@openpanel/redis'; | ||
| import { getRedisCache } from '@openpanel/redis'; | ||
| import type { | ||
| DeprecatedPostEventPayload, | ||
| IProjectFilterIp, | ||
|
|
@@ -39,6 +39,46 @@ export class SdkAuthError extends Error { | |
| } | ||
| } | ||
|
|
||
| const CLIENT_SECRET_CACHE_SEC = 60 * 5; | ||
|
|
||
| /** | ||
| * Checks a supplied client secret against the stored hash. | ||
| * | ||
| * Only successful verifications are cached. The cache key contains the | ||
| * caller-supplied secret, so caching a negative result would let anyone create | ||
| * entries with keys of their choosing. A client with no stored secret skips the | ||
| * cache entirely. | ||
| */ | ||
| async function verifyClientSecret( | ||
| clientId: string, | ||
| clientSecret: string | undefined, | ||
| storedSecret: string | null | undefined | ||
| ): Promise<boolean> { | ||
| if (!(storedSecret && clientSecret)) { | ||
| return false; | ||
| } | ||
|
|
||
| const cacheKey = `client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`; | ||
|
|
||
| // Strict compare: entries written before only positives were cached may still | ||
| // hold "false". | ||
| if ((await getRedisCache().get(cacheKey)) === 'true') { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| return true; | ||
| } | ||
|
|
||
| const isVerified = await verifyPassword(clientSecret, storedSecret); | ||
|
|
||
| if (isVerified) { | ||
| getRedisCache() | ||
| .setex(cacheKey, CLIENT_SECRET_CACHE_SEC, 'true') | ||
| .catch(() => { | ||
| // ignore error | ||
| }); | ||
| } | ||
|
|
||
| return isVerified; | ||
| } | ||
|
|
||
| export async function validateSdkRequest( | ||
| req: FastifyRequest<{ | ||
| Body: ITrackHandlerPayload | DeprecatedPostEventPayload; | ||
|
|
@@ -59,10 +99,6 @@ export async function validateSdkRequest( | |
| clientSecretNew || clientSecretOld || clientSecretFromBody; | ||
| const origin = headers.origin; | ||
|
|
||
| if (clientSecret) { | ||
| req.clientSecretAuth = true; | ||
| } | ||
|
|
||
| const createError = (message: string) => | ||
| new SdkAuthError(message, { | ||
| clientId, | ||
|
|
@@ -95,6 +131,16 @@ export async function validateSdkRequest( | |
| throw createError('Ingestion: Client has no project'); | ||
| } | ||
|
|
||
| // Whether the supplied secret actually matches the stored hash. Everything | ||
| // downstream keys off this, not off the mere presence of a secret. | ||
| const secretVerified = await verifyClientSecret( | ||
| clientId, | ||
| clientSecret, | ||
| client.secret | ||
| ); | ||
|
Comment on lines
+136
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
echo '--- auth.ts outline ---'
ast-grep outline apps/api/src/utils/auth.ts
echo '--- auth.ts relevant sections ---'
sed -n '1,230p' apps/api/src/utils/auth.ts
echo '--- direct references ---'
rg -n --glob '*.ts' --glob '*.tsx' 'verifyClientSecret|validateSdkRequest|rateLimit|rateLimiter|throttle' apps packages | head -200Repository: Openpanel-dev/openpanel Length of output: 11698 🤖 get_repo_knowledge executed:
Length of output: 6161 🏁 Script executed: #!/bin/bash
set -eu
echo '--- client hook ---'
cat -n apps/api/src/hooks/client.hook.ts
echo '--- API route registration and ingestion handlers ---'
rg -n -C 4 --glob '*.ts' 'clientHook|client\.hook|validateSdkRequest|preHandler|track|ingest|rate.?limit|throttl' apps/api/src
echo '--- Fastify plugin registration ---'
rg -n -C 3 --glob '*.ts' 'addHook|register\(|onRequest|preValidation|preHandler|rate.?limit|throttl' apps/api/src | head -240Repository: Openpanel-dev/openpanel Length of output: 50379 Denial of Service (CWE-400): Uncontrolled Resource Consumption Reachability: External · Exploitability: Trivial Rate-limit failed secret verification before the CORS decision. The 🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Out of scope for this PR: this asks for a new per-client/source-IP rate limiter in front of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Would you like me to open a GitHub issue for the per-client and source-IP limiter? You are interacting with an AI system. |
||
|
|
||
| req.clientSecretAuth = secretVerified; | ||
|
|
||
| // Filter out blocked IPs | ||
| const ipFilter = client.project.filters.filter( | ||
| (filter): filter is IProjectFilterIp => filter.type === 'ip' | ||
|
|
@@ -119,10 +165,10 @@ export async function validateSdkRequest( | |
| path(['payload', 'properties', '__revenue'], req.body) ?? | ||
| path(['properties', '__revenue'], req.body); | ||
|
|
||
| // Only allow revenue tracking if it was sent with a client secret | ||
| // Only allow revenue tracking if it was sent with a verified client secret | ||
| // or if the project has allowUnsafeRevenueTracking enabled | ||
| if ( | ||
| !(client.project.allowUnsafeRevenueTracking || clientSecret) && | ||
| !(client.project.allowUnsafeRevenueTracking || secretVerified) && | ||
| typeof revenue !== 'undefined' | ||
| ) { | ||
| throw createError( | ||
|
|
@@ -160,16 +206,8 @@ export async function validateSdkRequest( | |
| } | ||
| } | ||
|
|
||
| if (client.secret && clientSecret) { | ||
| const isVerified = await getCache( | ||
| `client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`, | ||
| 60 * 5, | ||
| async () => await verifyPassword(clientSecret, client.secret!), | ||
| true | ||
| ); | ||
| if (isVerified) { | ||
| return client; | ||
| } | ||
| if (secretVerified) { | ||
| return client; | ||
| } | ||
|
|
||
| throw createError('Ingestion: Invalid cors or secret'); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.