Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e6436ef71 | |||
| dff2e657fe |
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'}});
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<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,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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user