From f92eee5950992c81721f705bfd097551500cd896 Mon Sep 17 00:00:00 2001 From: KIRTAN Date: Mon, 29 Jun 2026 15:08:29 +0530 Subject: [PATCH 1/7] fix(cache): invalidate both raw and hashed publishable keys and encrypt jwtSecret in Redis --- .../src/__tests__/redisCaching.test.js | 123 ++++++++++++++++++ packages/common/src/redis/redisCaching.js | 28 +++- 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 apps/public-api/src/__tests__/redisCaching.test.js diff --git a/apps/public-api/src/__tests__/redisCaching.test.js b/apps/public-api/src/__tests__/redisCaching.test.js new file mode 100644 index 000000000..e4f88dc00 --- /dev/null +++ b/apps/public-api/src/__tests__/redisCaching.test.js @@ -0,0 +1,123 @@ +'use strict'; + +process.env.ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +const store = new Map(); +const mockRedis = { + status: 'ready', + set: jest.fn((key, val) => { + store.set(key, val); + return Promise.resolve('OK'); + }), + get: jest.fn((key) => { + return Promise.resolve(store.get(key) || null); + }), + del: jest.fn((key) => { + const existed = store.has(key); + store.delete(key); + return Promise.resolve(existed ? 1 : 0); + }), +}; + +// Mock the redis configuration module used by packages/common +jest.mock('../../../../packages/common/src/config/redis', () => mockRedis); + +const { + setProjectByApiKeyCache, + getProjectByApiKeyCache, + deleteProjectByApiKeyCache, + setProjectById, + getProjectById, +} = require('../../../../packages/common/src/redis/redisCaching'); + +const { hashApiKey } = require('../../../../packages/common/src/utils/api'); + +describe('redisCaching functions', () => { + beforeEach(() => { + store.clear(); + jest.clearAllMocks(); + }); + + test('setProjectByApiKeyCache encrypts jwtSecret and getProjectByApiKeyCache decrypts it', async () => { + const project = { + _id: 'project_1', + name: 'Test Project', + jwtSecret: 'super-secret-jwt-key', + allowedDomains: ['*'], + }; + + await setProjectByApiKeyCache('pk_live_123', project); + + // Verify the mockRedis.set was called + expect(mockRedis.set).toHaveBeenCalled(); + + // Inspect stored value + const storedStr = store.get('project:apikey:pk_live_123'); + expect(storedStr).toBeDefined(); + + const storedData = JSON.parse(storedStr); + // jwtSecret should NOT be the raw plaintext string + expect(storedData.jwtSecret).not.toBe('super-secret-jwt-key'); + expect(storedData.jwtSecret).toHaveProperty('iv'); + expect(storedData.jwtSecret).toHaveProperty('encrypted'); + expect(storedData.jwtSecret).toHaveProperty('tag'); + + // Retrieve and verify it is decrypted + const retrieved = await getProjectByApiKeyCache('pk_live_123'); + expect(retrieved.jwtSecret).toBe('super-secret-jwt-key'); + expect(retrieved.name).toBe('Test Project'); + }); + + test('getProjectByApiKeyCache falls back gracefully to plaintext jwtSecret (migration path)', async () => { + // Manually store raw plaintext jwtSecret in Redis store + const oldData = { + _id: 'project_2', + name: 'Old Cache Project', + jwtSecret: 'plaintext-old-secret', + }; + store.set('project:apikey:pk_live_456', JSON.stringify(oldData)); + + const retrieved = await getProjectByApiKeyCache('pk_live_456'); + expect(retrieved.jwtSecret).toBe('plaintext-old-secret'); + }); + + test('setProjectById encrypts jwtSecret and getProjectById decrypts it', async () => { + const project = { + _id: 'project_id_1', + jwtSecret: 'id-secret', + }; + + await setProjectById('project_id_1', project); + + const storedStr = store.get('project:id:project_id_1'); + const storedData = JSON.parse(storedStr); + expect(storedData.jwtSecret).not.toBe('id-secret'); + + const retrieved = await getProjectById('project_id_1'); + expect(retrieved.jwtSecret).toBe('id-secret'); + }); + + test('deleteProjectByApiKeyCache invalidates both raw and hashed keys', async () => { + const rawKey = 'pk_live_some_test_key'; + const hashedKey = hashApiKey(rawKey); + + // Put mock entries for both keys + store.set(`project:apikey:${rawKey}`, 'raw_val'); + store.set(`project:apikey:${hashedKey}`, 'hashed_val'); + + await deleteProjectByApiKeyCache(rawKey); + + // Both keys should be deleted + expect(store.has(`project:apikey:${rawKey}`)).toBe(false); + expect(store.has(`project:apikey:${hashedKey}`)).toBe(false); + }); + + test('deleteProjectByApiKeyCache invalidates only the hashed key if a hashed key is passed', async () => { + const hashedKey = hashApiKey('pk_live_some_test_key'); + store.set(`project:apikey:${hashedKey}`, 'hashed_val'); + + await deleteProjectByApiKeyCache(hashedKey); + + expect(store.has(`project:apikey:${hashedKey}`)).toBe(false); + }); +}); diff --git a/packages/common/src/redis/redisCaching.js b/packages/common/src/redis/redisCaching.js index 1f04e3c55..b1554bd73 100644 --- a/packages/common/src/redis/redisCaching.js +++ b/packages/common/src/redis/redisCaching.js @@ -1,11 +1,18 @@ const redis = require("../config/redis"); +const { hashApiKey } = require("../utils/api"); +const { encrypt, decrypt } = require("../utils/encryption"); async function setProjectByApiKeyCache(api, project) { if (redis.status !== "ready") return; try { + const projectCopy = { ...project }; + if (projectCopy.jwtSecret && typeof projectCopy.jwtSecret === "string") { + projectCopy.jwtSecret = encrypt(projectCopy.jwtSecret); + } + console.time("cache stringify"); - const data = JSON.stringify(project); + const data = JSON.stringify(projectCopy); console.timeEnd("cache stringify"); console.time("redis set"); @@ -36,6 +43,10 @@ async function getProjectByApiKeyCache(api) { const parsedData = JSON.parse(data); console.timeEnd("cache parse"); + if (parsedData && parsedData.jwtSecret && typeof parsedData.jwtSecret === "object") { + parsedData.jwtSecret = decrypt(parsedData.jwtSecret); + } + return parsedData; } catch (err) { @@ -48,6 +59,10 @@ async function deleteProjectByApiKeyCache(api) { if (redis.status !== "ready") return; try { await redis.del(`project:apikey:${api}`); + if (typeof api === "string" && (api.startsWith("pk_live_") || api.startsWith("sk_live_"))) { + const hashed = hashApiKey(api); + await redis.del(`project:apikey:${hashed}`); + } } catch (err) { console.log(err); } @@ -57,8 +72,13 @@ async function setProjectById(id, project) { if (redis.status !== "ready") return; try { + const projectCopy = { ...project }; + if (projectCopy.jwtSecret && typeof projectCopy.jwtSecret === "string") { + projectCopy.jwtSecret = encrypt(projectCopy.jwtSecret); + } + console.time("cache stringify by id"); - const data = JSON.stringify(project); + const data = JSON.stringify(projectCopy); console.timeEnd("cache stringify by id"); console.time("redis set by id"); @@ -89,6 +109,10 @@ async function getProjectById(id) { const parsedData = JSON.parse(data); console.timeEnd("cache parse by id"); + if (parsedData && parsedData.jwtSecret && typeof parsedData.jwtSecret === "object") { + parsedData.jwtSecret = decrypt(parsedData.jwtSecret); + } + return parsedData; } catch (err) { From fbf4aa018171ad863efe1228212fa44d5161f8db Mon Sep 17 00:00:00 2001 From: KIRTAN Date: Mon, 29 Jun 2026 15:09:37 +0530 Subject: [PATCH 2/7] docs: add PR description file --- pr_description.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 pr_description.md diff --git a/pr_description.md b/pr_description.md new file mode 100644 index 000000000..1c6e2204d --- /dev/null +++ b/pr_description.md @@ -0,0 +1,38 @@ +# Fix allowedDomains cache invalidation and encrypt jwtSecret in Redis + +## ๐Ÿš€ Pull Request Description +Fixes # 286 + +This PR resolves two security and consistency issues related to project caching in Redis: +1. **Cache Invalidation Gap**: Corrects cache invalidation inside `deleteProjectByApiKeyCache`. When updating `allowedDomains` in the dashboard, the cache was not properly invalidated for publishable keys because the key was stored under the hashed value of the key (since `verifyApiKey.js` did not select `publishableKey` from the DB, resulting in it checking `undefined === apiKey` and caching it under the `hashedApi`). `deleteProjectByApiKeyCache` now deletes both raw and hashed versions of keys to guarantee proper invalidation. +2. **Plaintext Secrets**: Encrypts the sensitive `jwtSecret` field prior to writing to the Redis cache and decrypts it when retrieving. A graceful fallback is included to support reading existing plaintext entries without breaking active sessions. + +## ๐Ÿ› ๏ธ Type of Change +- [x] ๐Ÿ› Bug fix (non-breaking change which fixes an issue) +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ’ฅ Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿ“ Documentation update +- [ ] ๐ŸŽจ UI/UX improvement (Frontend only) +- [ ] โš™๏ธ Refactor / Chore + +## ๐Ÿงช Testing & Validation +### Backend Verification: +- [x] I have run `npm test` in the `backend/` directory and all tests passed. +- [ ] I have verified the API endpoints using Postman/Thunder Client. +- [x] New unit tests have been added (if applicable). + +### Frontend Verification: +- [ ] I have run `npm run lint` in the `frontend/` directory. +- [ ] Verified the UI changes on different screen sizes (Responsive). +- [ ] Checked for any console errors in the browser dev tools. + +## ๐Ÿ“ธ Screenshots / Recordings (Optional) +## โœ… Checklist +- [x] My code follows the code style of this project. +- [x] I have performed a self-review of my code. +- [x] I have commented my code, particularly in hard-to-understand areas. +- [x] My changes generate no new warnings or errors. +- [x] I have updated the documentation (README/Docs) accordingly. + +--- +Built with โค๏ธ for **urBackend**. From bdc9a2d7c330bd660042152f2bebeb9af2454c0f Mon Sep 17 00:00:00 2001 From: KIRTAN Date: Tue, 30 Jun 2026 12:08:19 +0530 Subject: [PATCH 3/7] Fix: treat cached jwtSecret null as cache miss --- .../src/middlewares/verifyApiKey.js | 245 +++++++++++------- 1 file changed, 153 insertions(+), 92 deletions(-) diff --git a/apps/public-api/src/middlewares/verifyApiKey.js b/apps/public-api/src/middlewares/verifyApiKey.js index 2334d0a5d..1c1be7c8b 100644 --- a/apps/public-api/src/middlewares/verifyApiKey.js +++ b/apps/public-api/src/middlewares/verifyApiKey.js @@ -1,47 +1,77 @@ -const { AppError } = require('@urbackend/common'); -const {Project} = require('@urbackend/common'); -const { hashApiKey } = require('@urbackend/common'); +const { AppError } = require("@urbackend/common"); +const { Project } = require("@urbackend/common"); +const { hashApiKey } = require("@urbackend/common"); const { - setProjectByApiKeyCache, - getProjectByApiKeyCache + setProjectByApiKeyCache, + getProjectByApiKeyCache, + deleteProjectByApiKeyCache, } = require("@urbackend/common"); module.exports = async (req, res, next) => { - try { - // x-api-key header is preferred. For browser-navigation endpoints (e.g. social OAuth start), - // a publishable key may be supplied via the `key` query parameter instead. - const headerKey = req.header('x-api-key'); - const queryKey = typeof req.query?.key === 'string' ? req.query.key : undefined; - - // Only allow publishable keys (pk_live_) via query param; secret keys must use the header. - const apiKey = headerKey || (queryKey?.startsWith('pk_live_') ? queryKey : undefined); - - // Striping the key from req.query immediately after reading so it is not forwarded to - // downstream middleware, controllers, or access logs. - if (req.query && typeof req.query === 'object') { - delete req.query.key; - } - - if (!apiKey) { - return next(new AppError(401, 'API key not found')); - } + try { + // x-api-key header is preferred. For browser-navigation endpoints (e.g. social OAuth start), + // a publishable key may be supplied via the `key` query parameter instead. + const headerKey = req.header("x-api-key"); + const queryKey = + typeof req.query?.key === "string" ? req.query.key : undefined; + + // Only allow publishable keys (pk_live_) via query param; secret keys must use the header. + const apiKey = + headerKey || (queryKey?.startsWith("pk_live_") ? queryKey : undefined); + + // Striping the key from req.query immediately after reading so it is not forwarded to + // downstream middleware, controllers, or access logs. + if (req.query && typeof req.query === "object") { + delete req.query.key; + } - const isSecret = apiKey.startsWith('sk_live_'); - const keyField = isSecret ? 'secretKey' : 'publishableKey'; - const hashedApi = hashApiKey(apiKey); + if (!apiKey) { + return next(new AppError(401, "API key not found")); + } - let project = await getProjectByApiKeyCache(isSecret ? hashedApi : apiKey); - if (!project && !isSecret) { - project = await getProjectByApiKeyCache(hashedApi); + const isSecret = apiKey.startsWith("sk_live_"); + const keyField = isSecret ? "secretKey" : "publishableKey"; + const hashedApi = hashApiKey(apiKey); + + let project = await getProjectByApiKeyCache(isSecret ? hashedApi : apiKey); + + // Stability: if cached jwtSecret decrypt previously failed, readers may return + // { jwtSecret: null }. Treat it as a cache miss and refetch instead. + const invalidateIfJwtSecretIsNull = async (p) => { + if (!p || p.jwtSecret !== null) return p; + + try { + if (isSecret) { + // For secret keys we only ever read hashedApi. + await deleteProjectByApiKeyCache(hashedApi); + } else { + // For publishable keys we may have cached under both raw apiKey and hashedApi. + // (Depending on the producer/reader mismatch.) + await deleteProjectByApiKeyCache(apiKey); + await deleteProjectByApiKeyCache(hashedApi); } + } catch (e) { + // Deletion failure should not block refetch. + console.error("[verifyApiKey] cache invalidation failed:", e); + } + return null; + }; + + project = await invalidateIfJwtSecretIsNull(project); + + if (!project && !isSecret) { + project = await getProjectByApiKeyCache(hashedApi); + project = await invalidateIfJwtSecretIsNull(project); + } - if (!project) { - const queryCondition = isSecret - ? { [keyField]: hashedApi } - : { $or: [{ [keyField]: apiKey }, { [keyField]: hashedApi }] }; + if (!project) { + const queryCondition = isSecret + ? { [keyField]: hashedApi } + : { $or: [{ [keyField]: apiKey }, { [keyField]: hashedApi }] }; - project = await Project.findOne(queryCondition) - .select(` + project = await Project.findOne(queryCondition) + .select( + ` name owner resources @@ -54,69 +84,100 @@ module.exports = async (req, res, next) => { allowedDomains isAuthEnabled siteUrl - `) - .populate('owner', 'isVerified') - .lean(); + `, + ) + .populate("owner", "isVerified") + .lean(); + + if (!project) { + return next( + new AppError( + 401, + "Please use a valid API key or regenerate a new one from the dashboard.", + "API key is expired or invalid.", + ), + ); + } + + const cacheKey = isSecret + ? hashedApi + : project[keyField] === apiKey + ? apiKey + : hashedApi; + await setProjectByApiKeyCache(cacheKey, project); + } - if (!project) { - return next(new AppError(401, 'Please use a valid API key or regenerate a new one from the dashboard.', 'API key is expired or invalid.')); - } + if (!project.owner.isVerified) { + return next( + new AppError( + 401, + "Verify your account on https://urbackend.bitbros.in/dashboard", + "Owner not verified", + ), + ); + } - const cacheKey = isSecret ? hashedApi : (project[keyField] === apiKey ? apiKey : hashedApi); - await setProjectByApiKeyCache(cacheKey, project); + if (!project.resources) project.resources = {}; + if (!project.resources.db) project.resources.db = { isExternal: false }; + if (!project.resources.storage) + project.resources.storage = { isExternal: false }; + + if (!isSecret) { + let allowedDomains = project.allowedDomains || ["*"]; + const origin = req.headers.origin || req.headers.referer; + + if (!allowedDomains.includes("*")) { + if (!origin) { + return next( + new AppError( + 403, + "Forbidden: Origin header missing and this key is restricted to specific domains.", + ), + ); } - if (!project.owner.isVerified) { - return next(new AppError(401, 'Verify your account on https://urbackend.bitbros.in/dashboard', 'Owner not verified')); - } + try { + const parsedOrigin = new URL(origin); + const originUrl = parsedOrigin.origin; + const originHostname = parsedOrigin.hostname; - if (!project.resources) project.resources = {}; - if (!project.resources.db) project.resources.db = { isExternal: false }; - if (!project.resources.storage) project.resources.storage = { isExternal: false }; - - if (!isSecret) { - let allowedDomains = project.allowedDomains || ['*']; - const origin = req.headers.origin || req.headers.referer; - - if (!allowedDomains.includes('*')) { - if (!origin) { - return next(new AppError(403, 'Forbidden: Origin header missing and this key is restricted to specific domains.')); - } - - try { - const parsedOrigin = new URL(origin); - const originUrl = parsedOrigin.origin; - const originHostname = parsedOrigin.hostname; - - const isAllowed = allowedDomains.some(domain => { - let cleanDomain = domain.trim(); - if (cleanDomain.endsWith('/')) { - cleanDomain = cleanDomain.slice(0, -1); - } - - if (cleanDomain.startsWith('*.')) { - const baseDomain = cleanDomain.substring(2); - return originHostname === baseDomain || originHostname.endsWith('.' + baseDomain); - } - - return originUrl === cleanDomain || originHostname === cleanDomain; - }); - - if (!isAllowed) { - return next(new AppError(403, `Forbidden: Origin ${originUrl} is not allowed by this project's CORS policy.`)); - } - } catch (err) { - return next(new AppError(400, 'Invalid Origin header format.')); - } + const isAllowed = allowedDomains.some((domain) => { + let cleanDomain = domain.trim(); + if (cleanDomain.endsWith("/")) { + cleanDomain = cleanDomain.slice(0, -1); } - } - req.project = project; - req.hashedApiKey = hashedApi; - req.keyRole = isSecret ? 'secret' : 'publishable'; - next(); - } catch (err) { - console.error('[verifyApiKey] Unexpected error:', err); - next(new AppError(500, 'Internal Server Error')); + if (cleanDomain.startsWith("*.")) { + const baseDomain = cleanDomain.substring(2); + return ( + originHostname === baseDomain || + originHostname.endsWith("." + baseDomain) + ); + } + + return originUrl === cleanDomain || originHostname === cleanDomain; + }); + + if (!isAllowed) { + return next( + new AppError( + 403, + `Forbidden: Origin ${originUrl} is not allowed by this project's CORS policy.`, + ), + ); + } + } catch (err) { + return next(new AppError(400, "Invalid Origin header format.")); + } + } } + + req.project = project; + req.hashedApiKey = hashedApi; + req.keyRole = isSecret ? "secret" : "publishable"; + next(); + } catch (err) { + console.error("[verifyApiKey] Unexpected error:", err); + next(new AppError(500, "Internal Server Error")); + } }; From 8fa7b36aa67aa3c214c074d949595466e4d1a844 Mon Sep 17 00:00:00 2001 From: Kirtan-pc Date: Wed, 8 Jul 2026 22:53:22 +0530 Subject: [PATCH 4/7] fix: coderabbit comment --- .../src/__tests__/verifyApiKey.test.js | 13 ++++++++- .../src/middlewares/verifyApiKey.js | 12 +++++++- package-lock.json | 28 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/public-api/src/__tests__/verifyApiKey.test.js b/apps/public-api/src/__tests__/verifyApiKey.test.js index 892ca46eb..ed4b3f1cc 100644 --- a/apps/public-api/src/__tests__/verifyApiKey.test.js +++ b/apps/public-api/src/__tests__/verifyApiKey.test.js @@ -29,7 +29,7 @@ jest.mock('@urbackend/common', () => { }; }); -const { hashApiKey, getProjectByApiKeyCache, Project } = require('@urbackend/common'); +const { hashApiKey, getProjectByApiKeyCache, setProjectByApiKeyCache, Project } = require('@urbackend/common'); const verifyApiKey = require('../middlewares/verifyApiKey'); // --------------------------------------------------------------------------- @@ -175,6 +175,17 @@ describe('verifyApiKey middleware', () => { expect(res.status).not.toHaveBeenCalled(); }); + test('does not cache project when owner is not verified', async () => { + mockLean.mockResolvedValueOnce(makeProject({ owner: { isVerified: false } })); + const req = makeReq({ query: { key: 'pk_live_unverifiedowner' } }); + const res = makeRes(); + + await verifyApiKey(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'Owner not verified' })); + expect(setProjectByApiKeyCache).not.toHaveBeenCalled(); + }); + test('uses cache when available and does not query DB', async () => { getProjectByApiKeyCache.mockResolvedValueOnce(makeProject()); const req = makeReq({ query: { key: 'pk_live_cached' } }); diff --git a/apps/public-api/src/middlewares/verifyApiKey.js b/apps/public-api/src/middlewares/verifyApiKey.js index 1c1be7c8b..26d597136 100644 --- a/apps/public-api/src/middlewares/verifyApiKey.js +++ b/apps/public-api/src/middlewares/verifyApiKey.js @@ -99,6 +99,16 @@ module.exports = async (req, res, next) => { ); } + if (!project.owner?.isVerified) { + return next( + new AppError( + 401, + "Verify your account on https://urbackend.bitbros.in/dashboard", + "Owner not verified", + ), + ); + } + const cacheKey = isSecret ? hashedApi : project[keyField] === apiKey @@ -107,7 +117,7 @@ module.exports = async (req, res, next) => { await setProjectByApiKeyCache(cacheKey, project); } - if (!project.owner.isVerified) { + if (!project.owner?.isVerified) { return next( new AppError( 401, diff --git a/package-lock.json b/package-lock.json index a00ddc3e4..17775c901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2991,6 +2991,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3024,6 +3025,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3057,6 +3059,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3122,6 +3125,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7682,6 +7686,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7698,6 +7703,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7714,6 +7720,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7730,6 +7737,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7746,6 +7754,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7762,6 +7771,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7778,6 +7788,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7794,6 +7805,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7810,6 +7822,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7826,6 +7839,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7842,6 +7856,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7858,6 +7873,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7874,6 +7890,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7890,6 +7907,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7906,6 +7924,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7922,6 +7941,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7938,6 +7958,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7954,6 +7975,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7970,6 +7992,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7986,6 +8009,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8002,6 +8026,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8018,6 +8043,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -16266,7 +16292,7 @@ }, "sdks/urbackend-cli": { "name": "@urbackend/cli", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "commander": "^12.1.0" From 3ffd06e5db87f3ee93aa69ed812386c2b6867acb Mon Sep 17 00:00:00 2001 From: Kirtan Devadiga Date: Fri, 10 Jul 2026 12:43:37 +0530 Subject: [PATCH 5/7] Delete pr_description.md --- pr_description.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 pr_description.md diff --git a/pr_description.md b/pr_description.md deleted file mode 100644 index 1c6e2204d..000000000 --- a/pr_description.md +++ /dev/null @@ -1,38 +0,0 @@ -# Fix allowedDomains cache invalidation and encrypt jwtSecret in Redis - -## ๐Ÿš€ Pull Request Description -Fixes # 286 - -This PR resolves two security and consistency issues related to project caching in Redis: -1. **Cache Invalidation Gap**: Corrects cache invalidation inside `deleteProjectByApiKeyCache`. When updating `allowedDomains` in the dashboard, the cache was not properly invalidated for publishable keys because the key was stored under the hashed value of the key (since `verifyApiKey.js` did not select `publishableKey` from the DB, resulting in it checking `undefined === apiKey` and caching it under the `hashedApi`). `deleteProjectByApiKeyCache` now deletes both raw and hashed versions of keys to guarantee proper invalidation. -2. **Plaintext Secrets**: Encrypts the sensitive `jwtSecret` field prior to writing to the Redis cache and decrypts it when retrieving. A graceful fallback is included to support reading existing plaintext entries without breaking active sessions. - -## ๐Ÿ› ๏ธ Type of Change -- [x] ๐Ÿ› Bug fix (non-breaking change which fixes an issue) -- [ ] โœจ New feature (non-breaking change which adds functionality) -- [ ] ๐Ÿ’ฅ Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] ๐Ÿ“ Documentation update -- [ ] ๐ŸŽจ UI/UX improvement (Frontend only) -- [ ] โš™๏ธ Refactor / Chore - -## ๐Ÿงช Testing & Validation -### Backend Verification: -- [x] I have run `npm test` in the `backend/` directory and all tests passed. -- [ ] I have verified the API endpoints using Postman/Thunder Client. -- [x] New unit tests have been added (if applicable). - -### Frontend Verification: -- [ ] I have run `npm run lint` in the `frontend/` directory. -- [ ] Verified the UI changes on different screen sizes (Responsive). -- [ ] Checked for any console errors in the browser dev tools. - -## ๐Ÿ“ธ Screenshots / Recordings (Optional) -## โœ… Checklist -- [x] My code follows the code style of this project. -- [x] I have performed a self-review of my code. -- [x] I have commented my code, particularly in hard-to-understand areas. -- [x] My changes generate no new warnings or errors. -- [x] I have updated the documentation (README/Docs) accordingly. - ---- -Built with โค๏ธ for **urBackend**. From fc852f9287ed0eb5969de62cbc587d806f78db1a Mon Sep 17 00:00:00 2001 From: Yash Pouranik Date: Fri, 10 Jul 2026 12:51:06 +0530 Subject: [PATCH 6/7] Improve jwtSecret decryption handling Handle decryption failure for jwtSecret in cache. --- packages/common/src/redis/redisCaching.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/common/src/redis/redisCaching.js b/packages/common/src/redis/redisCaching.js index b1554bd73..c527e0f97 100644 --- a/packages/common/src/redis/redisCaching.js +++ b/packages/common/src/redis/redisCaching.js @@ -44,7 +44,13 @@ async function getProjectByApiKeyCache(api) { console.timeEnd("cache parse"); if (parsedData && parsedData.jwtSecret && typeof parsedData.jwtSecret === "object") { - parsedData.jwtSecret = decrypt(parsedData.jwtSecret); + const decryptedJwtSecret = decrypt(parsedData.jwtSecret); + if (decryptedJwtSecret == null) { + await redis.del(cacheKey); + return null; + } + + parsedData.jwtSecret = decryptedJwtSecret; } return parsedData; @@ -174,4 +180,4 @@ module.exports = { setDeveloperPlanCache, getDeveloperPlanCache, deleteDeveloperPlanCache -}; \ No newline at end of file +}; From edde7614676726b10866a71312ae386da97a2301 Mon Sep 17 00:00:00 2001 From: Yash Pouranik Date: Fri, 10 Jul 2026 12:57:18 +0530 Subject: [PATCH 7/7] fix: redis delete key in failed decryption of project.jwt --- packages/common/src/redis/redisCaching.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/redis/redisCaching.js b/packages/common/src/redis/redisCaching.js index c527e0f97..8872fe8a0 100644 --- a/packages/common/src/redis/redisCaching.js +++ b/packages/common/src/redis/redisCaching.js @@ -46,7 +46,7 @@ async function getProjectByApiKeyCache(api) { if (parsedData && parsedData.jwtSecret && typeof parsedData.jwtSecret === "object") { const decryptedJwtSecret = decrypt(parsedData.jwtSecret); if (decryptedJwtSecret == null) { - await redis.del(cacheKey); + await redis.del(`project:apikey:${api}`); return null; }