Compare commits

...
11 changed files with 174 additions and 21 deletions
+2 -1
View File
@@ -41,7 +41,8 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"sanitize-html": "^2.17.3", "sanitize-html": "^2.17.3",
"signale": "^1.4.0", "signale": "^1.4.0",
"stripe": "^20.0.0" "stripe": "^20.0.0",
"tldts": "^7.0.30"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
@@ -374,13 +374,14 @@ describe('Domain Verification and Ownership Tests', () => {
// EDGE CASES // EDGE CASES
// ======================================== // ========================================
describe('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(); 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'); 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 () => { it('should handle subdomain vs root domain', async () => {
+4 -1
View File
@@ -321,9 +321,12 @@ export class Auth {
data: {password: hashedPassword}, 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.passwordResetToken(token));
await redis.del(Keys.User.id(userId)); 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'}}); return res.json({success: true, data: {message: 'Password reset successfully'}});
} }
+16
View File
@@ -12,6 +12,7 @@ import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js'; import {BillingLimitService} from '../services/BillingLimitService.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {ProjectService} from '../services/ProjectService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js'; import {UserService} from '../services/UserService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -117,6 +118,8 @@ export class Users {
data, data,
}); });
await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);
return res.status(200).json(project); return res.status(200).json(project);
} }
@@ -130,6 +133,12 @@ export class Users {
// Verify user has admin/owner access to this project // Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id); 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 // Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`; const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${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 // Send notification about API key regeneration
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!); await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
+9
View File
@@ -19,6 +19,7 @@ import {EventService} from '../services/EventService.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
import {MeterService} from '../services/MeterService.js'; import {MeterService} from '../services/MeterService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {ProjectService} from '../services/ProjectService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.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 // Base onboarding credit: refund the 1-unit card-verification charge
let creditBalance = -100; let creditBalance = -100;
@@ -609,6 +614,8 @@ export class Webhooks {
data: {disabled: true}, data: {disabled: true},
}); });
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id); await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
// Send email notification to project members // 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})`); signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Send notification about subscription cancellation // Send notification about subscription cancellation
+23 -11
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import signale from 'signale'; import signale from 'signale';
import {getDomain as getRegistrableDomain} from 'tldts';
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email'; import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js'; import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -16,6 +17,14 @@ import {
} from './SESService.js'; } from './SESService.js';
export class DomainService { 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 * Get a domain by ID
*/ */
@@ -41,14 +50,16 @@ export class DomainService {
* Add a new domain to a project and start verification * Add a new domain to a project and start verification
*/ */
public static async addDomain(projectId: string, domain: string) { public static async addDomain(projectId: string, domain: string) {
const canonical = this.canonicalize(domain);
// Start verification process with AWS SES // Start verification process with AWS SES
const dkimTokens = await verifyDomain(domain); const dkimTokens = await verifyDomain(canonical);
// Create domain record // Create domain record
const newDomain = await prisma.domain.create({ const newDomain = await prisma.domain.create({
data: { data: {
projectId, projectId,
domain, domain: canonical,
verified: false, verified: false,
dkimTokens, dkimTokens,
}, },
@@ -60,7 +71,7 @@ export class DomainService {
}); });
// Send notification about domain added // Send notification about domain added
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId); await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId);
return newDomain; return newDomain;
} }
@@ -353,7 +364,7 @@ export class DomainService {
throw new HttpException(400, 'Invalid email format'); throw new HttpException(400, 'Invalid email format');
} }
const domainName = emailParts[1]; const domainName = this.canonicalize(emailParts[1] ?? '');
// Find domain in database // Find domain in database
const domain = await prisma.domain.findFirst({ 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. * Extract the registrable root domain from a domain name using the Public Suffix List.
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com" * e.g. "mail.example.com" → "example.com", "mail.example.co.uk" → "example.co.uk"
*/ */
private static rootDomain(domain: string): string { private static rootDomain(domain: string): string {
const parts = domain.split('.'); return getRegistrableDomain(domain) ?? domain;
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
} }
/** /**
@@ -404,10 +414,11 @@ export class DomainService {
public static async checkSubdomainOfDisabledRoot( public static async checkSubdomainOfDisabledRoot(
domain: string, domain: string,
): Promise<{blocked: boolean; projectName?: string; projectId?: 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 // Only relevant when the submitted domain is actually a subdomain
if (root === domain) { if (root === canonical) {
return {blocked: false}; return {blocked: false};
} }
@@ -439,8 +450,9 @@ export class DomainService {
* @returns Object with exists flag and membership info * @returns Object with exists flag and membership info
*/ */
public static async checkDomainOwnership(domain: string, userId: string) { public static async checkDomainOwnership(domain: string, userId: string) {
const canonical = this.canonicalize(domain);
const existingDomain = await prisma.domain.findFirst({ const existingDomain = await prisma.domain.findFirst({
where: {domain}, where: {domain: canonical},
include: { include: {
project: { project: {
include: { include: {
+27 -1
View File
@@ -1,5 +1,7 @@
import signale from 'signale';
import {Keys} from './keys.js'; import {Keys} from './keys.js';
import {wrapRedis} from '../database/redis.js'; import {redis, wrapRedis} from '../database/redis.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
export class ProjectService { 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<void> {
try {
const cacheKeys = new Set<string>([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);
}
}
} }
+9 -2
View File
@@ -9,6 +9,7 @@ import {redis} from '../database/redis.js';
import {Keys} from './keys.js'; import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js'; import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {ProjectService} from './ProjectService.js';
import {QueueService} from './QueueService.js'; import {QueueService} from './QueueService.js';
import { import {
AUTO_PROJECT_DISABLE, AUTO_PROJECT_DISABLE,
@@ -711,11 +712,14 @@ export class SecurityService {
} }
// Disable the project // Disable the project
await prisma.project.update({ const disabled = await prisma.project.update({
where: {id: projectId}, where: {id: projectId},
data: {disabled: true}, data: {disabled: true},
select: {public: true, secret: true},
}); });
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
// Log critical security event // Log critical security event
signale.error( signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`, `[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
@@ -969,11 +973,14 @@ ${strippedBody.substring(0, 2000)}`,
} }
// Disable the project // Disable the project
await prisma.project.update({ const disabled = await prisma.project.update({
where: {id: projectId}, where: {id: projectId},
data: {disabled: true}, 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.`; const violation = `A policy violation was detected. Please contact support for more details.`;
// Log critical security event // Log critical security event
@@ -953,7 +953,10 @@ export class WorkflowExecutionService {
body: method !== 'GET' ? JSON.stringify(payload) : undefined, 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; let parsedResponse;
try { try {
parsedResponse = JSON.parse(responseData); parsedResponse = JSON.parse(responseData);
@@ -967,9 +970,65 @@ export class WorkflowExecutionService {
statusCode: response.status, statusCode: response.status,
success: response.ok, success: response.ok,
response: parsedResponse, 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 * UPDATE_CONTACT step - Update contact data
*/ */
+1 -1
View File
@@ -4,7 +4,7 @@ export const Keys = {
return `account:id:${id}`; return `account:id:${id}`;
}, },
email(email: string): string { email(email: string): string {
return `account:${email}`; return `account:${email.trim().toLowerCase()}`;
}, },
emailVerificationToken(token: string): string { emailVerificationToken(token: string): string {
return `auth:email_verification:${token}`; return `auth:email_verification:${token}`;
+19
View File
@@ -8104,6 +8104,7 @@ __metadata:
sanitize-html: "npm:^2.17.3" sanitize-html: "npm:^2.17.3"
signale: "npm:^1.4.0" signale: "npm:^1.4.0"
stripe: "npm:^20.0.0" stripe: "npm:^20.0.0"
tldts: "npm:^7.0.30"
tsx: "npm:^4.20.6" tsx: "npm:^4.20.6"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -18563,6 +18564,24 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "to-regex-range@npm:^5.0.1":
version: 5.0.1 version: 5.0.1
resolution: "to-regex-range@npm:5.0.1" resolution: "to-regex-range@npm:5.0.1"