Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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