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
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/__tests__/integration/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/controllers/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}});
}
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/controllers/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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';
Expand Down Expand Up @@ -64,7 +65,7 @@
}

// Check if user is a member of any disabled project
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);

Check warning on line 68 in apps/api/src/controllers/Users.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'disabledProjectNames' is assigned a value but never used. Allowed unused vars must match /^_/u
if (hasDisabledProject) {
throw new HttpException(
403,
Expand Down Expand Up @@ -117,6 +118,8 @@
data,
});

await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);

return res.status(200).json(project);
}

Expand All @@ -130,6 +133,12 @@
// 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')}`;
Expand All @@ -154,6 +163,13 @@
},
});

// 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!);

Expand Down Expand Up @@ -431,7 +447,7 @@
upcomingInvoice = (await stripe.invoices.createPreview({
customer: project.customer,
subscription: project.subscription,
})) as any;

Check warning on line 450 in apps/api/src/controllers/Users.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type

// Extract metered usage from invoice line items
if (upcomingInvoice && upcomingInvoice.lines && upcomingInvoice.lines.data) {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/controllers/Webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 23 additions & 11 deletions apps/api/src/services/DomainService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(/\.$/, '');
Comment on lines +24 to +25
}

/**
* Get a domain by ID
*/
Expand All @@ -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,
},
Expand All @@ -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;
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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};
}

Expand Down Expand Up @@ -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: {
Expand Down
28 changes: 27 additions & 1 deletion apps/api/src/services/ProjectService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}
}
}
11 changes: 9 additions & 2 deletions apps/api/src/services/SecurityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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,
Expand Down Expand Up @@ -711,11 +712,14 @@
}

// 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:`,
Expand Down Expand Up @@ -947,9 +951,9 @@
*/
public static async disableProjectForPhishing(
projectId: string,
subject: string,

Check warning on line 954 in apps/api/src/services/SecurityService.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'subject' is defined but never used. Allowed unused args must match /^_/u
confidence: number,

Check warning on line 955 in apps/api/src/services/SecurityService.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'confidence' is defined but never used. Allowed unused args must match /^_/u
reason?: string,

Check warning on line 956 in apps/api/src/services/SecurityService.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'reason' is defined but never used. Allowed unused args must match /^_/u
): Promise<void> {
try {
// Check if already disabled to avoid duplicate logs
Expand All @@ -969,11 +973,14 @@
}

// 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
Expand Down
61 changes: 60 additions & 1 deletion apps/api/src/services/WorkflowExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Comment on lines +956 to +959
let parsedResponse;
try {
parsedResponse = JSON.parse(responseData);
Expand All @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading
Loading