diff --git a/apps/api/package.json b/apps/api/package.json index eb1f1d38..41f7b521 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -41,7 +41,8 @@ "multer": "^2.1.1", "sanitize-html": "^2.17.3", "signale": "^1.4.0", - "stripe": "^20.0.0" + "stripe": "^20.0.0", + "tldts": "^7.0.30" }, "devDependencies": { "@types/bcrypt": "^6.0.0", diff --git a/apps/api/src/__tests__/integration/domains.test.ts b/apps/api/src/__tests__/integration/domains.test.ts index 8907cebc..159b6404 100644 --- a/apps/api/src/__tests__/integration/domains.test.ts +++ b/apps/api/src/__tests__/integration/domains.test.ts @@ -374,13 +374,14 @@ describe('Domain Verification and Ownership Tests', () => { // EDGE CASES // ======================================== describe('Edge Cases', () => { - it('should handle case-sensitive domain names', async () => { + it('should canonicalize mixed-case domain names to lowercase', async () => { const {project} = await factories.createUserWithProject(); - // Domains are typically case-insensitive in DNS, but stored as-is in DB + // DNS is case-insensitive — domains must be stored canonically so a tenant + // can't claim "Example.com" while another project owns "example.com". const domain1 = await DomainService.addDomain(project.id, 'Example.com'); - expect(domain1.domain).toBe('Example.com'); + expect(domain1.domain).toBe('example.com'); }); it('should handle subdomain vs root domain', async () => { diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index c6b7b668..749235af 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -321,9 +321,12 @@ export class Auth { data: {password: hashedPassword}, }); - // Delete token and invalidate cache + // Delete token and invalidate cache (id + email projections both cache the password hash) await redis.del(Keys.User.passwordResetToken(token)); await redis.del(Keys.User.id(userId)); + if (user.email) { + await redis.del(Keys.User.email(user.email)); + } return res.json({success: true, data: {message: 'Password reset successfully'}}); } diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 160a2635..20762f13 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -12,6 +12,7 @@ import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; import {MembershipService} from '../services/MembershipService.js'; import {NtfyService} from '../services/NtfyService.js'; +import {ProjectService} from '../services/ProjectService.js'; import {SecurityService} from '../services/SecurityService.js'; import {UserService} from '../services/UserService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -117,6 +118,8 @@ export class Users { data, }); + await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]); + return res.status(200).json(project); } @@ -130,6 +133,12 @@ export class Users { // Verify user has admin/owner access to this project await MembershipService.requireAdminAccess(auth.userId!, id); + // Capture the existing keys so we can drop them from cache after rotation + const previousProject = await prisma.project.findUnique({ + where: {id}, + select: {public: true, secret: true}, + }); + // Generate new unique API keys const publicKey = `pk_${randomBytes(32).toString('hex')}`; const secretKey = `sk_${randomBytes(32).toString('hex')}`; @@ -154,6 +163,13 @@ export class Users { }, }); + // Invalidate cached lookups for both old and new keys so revoked keys + // stop authorizing requests immediately instead of after cache TTL. + await ProjectService.invalidate(id, [ + {public: previousProject?.public, secret: previousProject?.secret}, + {public: project.public, secret: project.secret}, + ]); + // Send notification about API key regeneration await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!); diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index 9ac0c3cd..8344c6a7 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -19,6 +19,7 @@ import {EventService} from '../services/EventService.js'; import {MembershipService} from '../services/MembershipService.js'; import {MeterService} from '../services/MeterService.js'; import {NtfyService} from '../services/NtfyService.js'; +import {ProjectService} from '../services/ProjectService.js'; import {SecurityService} from '../services/SecurityService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -530,6 +531,10 @@ export class Webhooks { }, }); + await ProjectService.invalidate(projectId, [ + {public: updatedProject.public, secret: updatedProject.secret}, + ]); + // Base onboarding credit: refund the 1-unit card-verification charge let creditBalance = -100; @@ -609,6 +614,8 @@ export class Webhooks { data: {disabled: true}, }); + await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]); + await NtfyService.notifyProjectDisabledForPayment(project.name, project.id); // Send email notification to project members @@ -654,6 +661,8 @@ export class Webhooks { }, }); + await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]); + signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`); // Send notification about subscription cancellation diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index da2562dc..91c3bbd7 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -1,5 +1,6 @@ import React from 'react'; import signale from 'signale'; +import {getDomain as getRegistrableDomain} from 'tldts'; import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email'; import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; @@ -16,6 +17,14 @@ import { } from './SESService.js'; export class DomainService { + /** + * Canonicalize a domain name for storage and comparison. + * DNS is case-insensitive and a trailing dot represents the same name. + */ + public static canonicalize(domain: string): string { + return domain.trim().toLowerCase().replace(/\.$/, ''); + } + /** * Get a domain by ID */ @@ -41,14 +50,16 @@ export class DomainService { * Add a new domain to a project and start verification */ public static async addDomain(projectId: string, domain: string) { + const canonical = this.canonicalize(domain); + // Start verification process with AWS SES - const dkimTokens = await verifyDomain(domain); + const dkimTokens = await verifyDomain(canonical); // Create domain record const newDomain = await prisma.domain.create({ data: { projectId, - domain, + domain: canonical, verified: false, dkimTokens, }, @@ -60,7 +71,7 @@ export class DomainService { }); // Send notification about domain added - await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId); + await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId); return newDomain; } @@ -353,7 +364,7 @@ export class DomainService { throw new HttpException(400, 'Invalid email format'); } - const domainName = emailParts[1]; + const domainName = this.canonicalize(emailParts[1] ?? ''); // Find domain in database const domain = await prisma.domain.findFirst({ @@ -389,12 +400,11 @@ export class DomainService { } /** - * Extract the registrable root domain (last two labels) from a domain name. - * e.g. "mail.example.com" → "example.com", "example.com" → "example.com" + * Extract the registrable root domain from a domain name using the Public Suffix List. + * e.g. "mail.example.com" → "example.com", "mail.example.co.uk" → "example.co.uk" */ private static rootDomain(domain: string): string { - const parts = domain.split('.'); - return parts.length > 2 ? parts.slice(-2).join('.') : domain; + return getRegistrableDomain(domain) ?? domain; } /** @@ -404,10 +414,11 @@ export class DomainService { public static async checkSubdomainOfDisabledRoot( domain: string, ): Promise<{blocked: boolean; projectName?: string; projectId?: string}> { - const root = this.rootDomain(domain); + const canonical = this.canonicalize(domain); + const root = this.rootDomain(canonical); // Only relevant when the submitted domain is actually a subdomain - if (root === domain) { + if (root === canonical) { return {blocked: false}; } @@ -439,8 +450,9 @@ export class DomainService { * @returns Object with exists flag and membership info */ public static async checkDomainOwnership(domain: string, userId: string) { + const canonical = this.canonicalize(domain); const existingDomain = await prisma.domain.findFirst({ - where: {domain}, + where: {domain: canonical}, include: { project: { include: { diff --git a/apps/api/src/services/ProjectService.ts b/apps/api/src/services/ProjectService.ts index 231867e8..30e5f87f 100644 --- a/apps/api/src/services/ProjectService.ts +++ b/apps/api/src/services/ProjectService.ts @@ -1,5 +1,7 @@ +import signale from 'signale'; + import {Keys} from './keys.js'; -import {wrapRedis} from '../database/redis.js'; +import {redis, wrapRedis} from '../database/redis.js'; import {prisma} from '../database/prisma.js'; export class ProjectService { @@ -28,4 +30,28 @@ export class ProjectService { }); }); } + + /** + * Invalidate cached project lookups (id + secret/public keys). + * Must be called whenever a project's API keys, `disabled` flag, or other + * auth-affecting fields change, otherwise stale records can keep + * revoked keys or just-disabled projects authorized until cache TTL. + * + * Accepts the previous key values too, so rotated keys are also dropped. + */ + public static async invalidate( + projectId: string, + keys?: {secret?: string | null; public?: string | null}[], + ): Promise { + try { + const cacheKeys = new Set([Keys.Project.id(projectId)]); + for (const k of keys ?? []) { + if (k.secret) cacheKeys.add(Keys.Project.secret(k.secret)); + if (k.public) cacheKeys.add(Keys.Project.public(k.public)); + } + await Promise.all([...cacheKeys].map(key => redis.del(key))); + } catch (error) { + signale.warn(`[PROJECT] Failed to invalidate cache for ${projectId}:`, error); + } + } } diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 1877ba4d..79657af4 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -9,6 +9,7 @@ import {redis} from '../database/redis.js'; import {Keys} from './keys.js'; import {MembershipService} from './MembershipService.js'; import {NtfyService} from './NtfyService.js'; +import {ProjectService} from './ProjectService.js'; import {QueueService} from './QueueService.js'; import { AUTO_PROJECT_DISABLE, @@ -711,11 +712,14 @@ export class SecurityService { } // Disable the project - await prisma.project.update({ + const disabled = await prisma.project.update({ where: {id: projectId}, data: {disabled: true}, + select: {public: true, secret: true}, }); + await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]); + // Log critical security event signale.error( `[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`, @@ -969,11 +973,14 @@ ${strippedBody.substring(0, 2000)}`, } // Disable the project - await prisma.project.update({ + const disabled = await prisma.project.update({ where: {id: projectId}, data: {disabled: true}, + select: {public: true, secret: true}, }); + await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]); + const violation = `A policy violation was detected. Please contact support for more details.`; // Log critical security event diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index ca340266..02a02cde 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -953,7 +953,10 @@ export class WorkflowExecutionService { body: method !== 'GET' ? JSON.stringify(payload) : undefined, }); - const responseData = await response.text(); + const {body: responseData, truncated} = await WorkflowExecutionService.readBodyCapped( + response, + WorkflowExecutionService.WEBHOOK_RESPONSE_MAX_BYTES, + ); let parsedResponse; try { parsedResponse = JSON.parse(responseData); @@ -967,9 +970,65 @@ export class WorkflowExecutionService { statusCode: response.status, success: response.ok, response: parsedResponse, + ...(truncated ? {truncated: true} : {}), }; } + private static readonly WEBHOOK_RESPONSE_MAX_BYTES = 64 * 1024; + + /** + * Read a fetch Response body up to a maximum number of bytes. + * Aborts further reading once the cap is reached so a malicious server + * cannot exhaust worker memory. + */ + private static async readBodyCapped( + response: Response, + maxBytes: number, + ): Promise<{body: string; truncated: boolean}> { + if (!response.body) { + return {body: '', truncated: false}; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + let truncated = false; + + try { + while (received < maxBytes) { + const {done, value} = await reader.read(); + if (done) break; + if (!value) continue; + + const remaining = maxBytes - received; + if (value.byteLength > remaining) { + chunks.push(value.subarray(0, remaining)); + received += remaining; + truncated = true; + break; + } + + chunks.push(value); + received += value.byteLength; + } + } finally { + try { + await reader.cancel(); + } catch { + // ignore + } + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + + return {body: new TextDecoder().decode(merged), truncated}; + } + /** * UPDATE_CONTACT step - Update contact data */ diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index 66ed85cc..d1be28f8 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -4,7 +4,7 @@ export const Keys = { return `account:id:${id}`; }, email(email: string): string { - return `account:${email}`; + return `account:${email.trim().toLowerCase()}`; }, emailVerificationToken(token: string): string { return `auth:email_verification:${token}`; diff --git a/yarn.lock b/yarn.lock index fc23a6a4..d522931c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8104,6 +8104,7 @@ __metadata: sanitize-html: "npm:^2.17.3" signale: "npm:^1.4.0" stripe: "npm:^20.0.0" + tldts: "npm:^7.0.30" tsx: "npm:^4.20.6" languageName: unknown linkType: soft @@ -18563,6 +18564,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^7.0.30": + version: 7.0.30 + resolution: "tldts-core@npm:7.0.30" + checksum: 10c0/e3cd730e96b0e9c0332fcaab44d0257b668f9089644508e4f6f870d37bbf5c218243b7e83aa39690c87b386d1b0ad577772a5994969c4c81cc25a476f783ccd7 + languageName: node + linkType: hard + +"tldts@npm:^7.0.30": + version: 7.0.30 + resolution: "tldts@npm:7.0.30" + dependencies: + tldts-core: "npm:^7.0.30" + bin: + tldts: bin/cli.js + checksum: 10c0/c36f7b480f09128303158e4738a82426c33e8da9f77d4fb57a2d5ef5896c803d7a3c1d53ade965712f9cb4946935139b6d192a18698665556ca504493c7c265e + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1"