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
123 changes: 123 additions & 0 deletions apps/public-api/src/__tests__/redisCaching.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
245 changes: 153 additions & 92 deletions apps/public-api/src/middlewares/verifyApiKey.js
Original file line number Diff line number Diff line change
@@ -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;

Check warning

Code scanning / CodeQL

Sensitive data read from GET request Medium

Route handler
for GET requests uses query parameter as sensitive data.

// 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
Expand All @@ -54,69 +84,100 @@
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) {
Comment on lines +102 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚑ Quick win

Move the cache write after owner verification.

Line 107 caches the project before Line 110 rejects unverified owners, so a request made before verification can store owner.isVerified: false and keep rejecting from Redis until the cache expires. Defer setProjectByApiKeyCache until after the owner check.

Proposed fix
+    let cacheKey;
+
     if (!project) {
@@
-      const cacheKey = isSecret
+      cacheKey = isSecret
         ? hashedApi
         : project[keyField] === apiKey
           ? apiKey
           : hashedApi;
-      await setProjectByApiKeyCache(cacheKey, project);
     }
 
-    if (!project.owner.isVerified) {
+    if (!project.owner?.isVerified) {
       return next(
         new AppError(
           401,
@@
       );
     }
+
+    if (cacheKey) {
+      await setProjectByApiKeyCache(cacheKey, project);
+    }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) {
let cacheKey;
cacheKey = isSecret
? hashedApi
: project[keyField] === apiKey
? apiKey
: hashedApi;
}
if (!project.owner?.isVerified) {
return next(
new AppError(
401,
...
)
);
}
if (cacheKey) {
await setProjectByApiKeyCache(cacheKey, project);
}
🧰 Tools
πŸͺ› ast-grep (0.44.0)

[warning] 106-106: Avoid using the initial state variable in setState
Context: setProjectByApiKeyCache(cacheKey, project)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/public-api/src/middlewares/verifyApiKey.js` around lines 102 - 110, The
cache write in verifyApiKey is happening before the owner verification check,
which can persist an unverified project state in Redis. Move the
setProjectByApiKeyCache call in verifyApiKey.js to after the
!project.owner.isVerified guard so only verified projects are cached, and keep
the existing cacheKey selection logic intact.

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"));
}
};
Loading
Loading