fix(platform): close remaining audit findings from security review

Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-07-03 12:30:22 +03:30
parent 6d9cd89cc5
commit 8163665c86
8 changed files with 294 additions and 87 deletions
@@ -52,9 +52,16 @@ export class Application {
@Column({ nullable: true })
dbUsername: string;
/** Never expose raw DB password in API responses — use hasDbPassword for UI. */
@Exclude({ toPlainOnly: true })
@Column({ nullable: true })
dbPassword: string;
@Expose()
get hasDbPassword(): boolean {
return !!this.dbPassword;
}
@Column({ nullable: true, default: '1Gi' })
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
+8
View File
@@ -555,18 +555,26 @@ export class BuildService {
'-c',
`
apk add --no-cache unzip tar gzip &&
reject_unsafe_path() {
case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac
} &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /tmp/extract &&
cd /tmp/extract &&
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected gzip tarball" &&
tar tzf /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
tar xzf /source-pvc/source.zip
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected zip archive" &&
unzip -Z1 /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
unzip -q /source-pvc/source.zip
else
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
fi &&
find /tmp/extract -mindepth 1 -print | while read -r path; do
case "$path" in /tmp/extract|/tmp/extract/*) ;; *) echo "ERROR: zip slip detected: $path" && exit 1;; esac
done &&
echo "--- Extracted contents ---" &&
ls -la /tmp/extract/ &&
mkdir -p /workspace-out/source &&
+24 -4
View File
@@ -9,6 +9,7 @@ import { BuildService, BuildProgress, BuildCancelledError } from '../build/build
import * as crypto from 'crypto';
import {
AppLifecycleStatus,
CustomDomainStatus,
DeploymentStatus,
isManagedProductType,
MANAGED_DEPLOY_MARKER,
@@ -100,6 +101,16 @@ export class DeploymentsService implements OnModuleInit {
this.ensureAppPaidAndActive(app, 'deploying');
const inFlight = await this.deploymentsRepository.findOne({
where: {
applicationId: app.id,
status: In([DeploymentStatus.PENDING, DeploymentStatus.BUILDING, DeploymentStatus.DEPLOYING]),
},
});
if (inFlight) {
throw new BadRequestException('A deployment is already in progress for this application');
}
// Create deployment record
const deployment = this.deploymentsRepository.create({
applicationId: app.id,
@@ -113,7 +124,7 @@ export class DeploymentsService implements OnModuleInit {
// Fill deterministic preview number after we have the deployment id.
let previewSubdomain: string | null = null;
if (!app.customDomain) {
if (!this.hasVerifiedCustomDomain(app)) {
previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
@@ -339,7 +350,9 @@ export class DeploymentsService implements OnModuleInit {
const failedClusterIds: string[] = [];
let currentApp = app;
let lastError: any;
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
: 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await this.isDeploymentCancelled(deploymentId)) {
@@ -409,7 +422,9 @@ export class DeploymentsService implements OnModuleInit {
const failedClusterIds: string[] = [];
let currentApp = app;
let lastError: any;
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
: 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await this.isDeploymentCancelled(deploymentId)) {
@@ -735,7 +750,7 @@ export class DeploymentsService implements OnModuleInit {
const saved = await this.deploymentsRepository.save(deployment);
let previewSubdomain: string | null = null;
if (!app.customDomain) {
if (!this.hasVerifiedCustomDomain(app)) {
previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
@@ -753,4 +768,9 @@ export class DeploymentsService implements OnModuleInit {
async deleteAllForApplication(applicationId: string): Promise<void> {
await this.deploymentsRepository.delete({ applicationId });
}
/** Preview stays available until the custom domain is verified (not merely requested). */
private hasVerifiedCustomDomain(app: { customDomain?: string | null; customDomainStatus?: CustomDomainStatus | null }): boolean {
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
}
}
+28 -41
View File
@@ -313,7 +313,9 @@ export class KubernetesService implements OnModuleInit {
const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = userIdSlug(app.userId);
const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
const previewHost = previewNumber && !this.hasVerifiedCustomDomain(app)
? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
: '';
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE;
@@ -2347,6 +2349,8 @@ export class KubernetesService implements OnModuleInit {
}
}
await this.deleteTemporaryAccessServicesForApp(app);
return snapshot;
}
@@ -2720,6 +2724,11 @@ export class KubernetesService implements OnModuleInit {
return userNamespace(userId);
}
/** Preview URL stays available until the custom domain is verified (not merely requested). */
private hasVerifiedCustomDomain(app: Application): boolean {
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
}
private getClusterHostIp(kc: k8s.KubeConfig): string {
const clusterServer = kc.getCurrentCluster()?.server || '';
try {
@@ -2955,7 +2964,7 @@ export class KubernetesService implements OnModuleInit {
/**
* Get preview info for a deployed application.
* Patches the service to NodePort if needed, and returns the access URL.
* Returns ingress URL when available; only reads an existing NodePort (never patches ClusterIP).
*/
async getPreviewInfo(
app: Application,
@@ -2966,48 +2975,11 @@ export class KubernetesService implements OnModuleInit {
host: string;
ingressUrl?: string;
}> {
const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const domain = this.configService.get('platform.domain');
const hostIp = this.getClusterHostIp(kc);
// Read current service
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService({
name: app.name,
namespace,
});
const svc = svcResponse;
if (svc.spec?.type === 'NodePort') {
// Already NodePort, read the assigned port
nodePort = svc.spec.ports?.[0]?.nodePort || 0;
} else {
// Patch ClusterIP → NodePort so we can access from outside
const patchBody = {
spec: {
type: 'NodePort',
ports: [
{
port: 80,
targetPort: app.port,
protocol: 'TCP',
},
],
},
};
const patchedResponse = await coreApi.patchNamespacedService({ name: app.name, namespace, body: patchBody }, k8s.setHeaderOptions('Content-Type', 'application/strategic-merge-patch+json'));
nodePort = patchedResponse.spec?.ports?.[0]?.nodePort || 0;
this.logger.log(`Patched service ${app.name} to NodePort: ${nodePort}`);
}
} catch (e: any) {
this.logger.warn(`Failed to get/patch service for ${app.name}: ${e.message}`);
throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`);
}
// Build ingress URL (main / custom domain / preview host)
const subdomain = app.subdomain || app.name;
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
@@ -3020,8 +2992,23 @@ export class KubernetesService implements OnModuleInit {
ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
}
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService({
name: app.name,
namespace,
});
if (svcResponse.spec?.type === 'NodePort') {
nodePort = svcResponse.spec.ports?.[0]?.nodePort || 0;
}
} catch (e: any) {
this.logger.warn(`Failed to read service for ${app.name}: ${e.message}`);
}
const url = ingressUrl || (nodePort > 0 ? `http://${hostIp}:${nodePort}` : '');
return {
url: `http://${hostIp}:${nodePort}`,
url,
nodePort,
host: hostIp,
ingressUrl,
+28 -18
View File
@@ -226,28 +226,38 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
if (!app.billingCycle) return false;
try {
const cost = await this.billingService.calculateCostForApp(app);
const hourlyAmount = cost.hourly;
if (hourlyAmount <= 0) return false;
return await this.appRepo.manager.transaction(async (em) => {
const locked = await em.findOne(Application, {
where: { id: app.id },
lock: { mode: 'pessimistic_write' },
});
if (!locked?.billingCycle) return false;
if (locked.lifecycleStatus !== AppLifecycleStatus.ACTIVE) return false;
if (locked.planExpiresAt && locked.planExpiresAt.getTime() > Date.now()) {
return false; // another replica already renewed
}
// Check wallet balance
const { balance } = await this.billingService.getBalance(app.userId);
if (balance < hourlyAmount) return false;
const cost = await this.billingService.calculateCostForApp(locked);
const hourlyAmount = cost.hourly;
if (hourlyAmount <= 0) return false;
// Deduct and renew
await this.billingService.deductWallet(
app.userId,
hourlyAmount,
`Auto-renew hourly: ${app.name}`,
app.id,
);
const { balance } = await this.billingService.getBalance(locked.userId);
if (balance < hourlyAmount) return false;
app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
app.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await this.appRepo.save(app);
await this.billingService.deductWallet(
locked.userId,
hourlyAmount,
`Auto-renew hourly: ${locked.name}`,
locked.id,
);
this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`);
return true;
locked.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
locked.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await em.save(locked);
this.logger.log(`Auto-renewed hourly plan for ${locked.name} — deducted ${hourlyAmount} Toman`);
return true;
});
} catch (e: any) {
this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`);
return false;
+27 -23
View File
@@ -13,6 +13,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThan, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { VerificationCode } from './entities/verification-code.entity';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@@ -185,7 +186,7 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
{ consumedAt: new Date() },
);
const code = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits
const code = String(crypto.randomInt(100000, 1000000)); // 6 digits, CSPRNG
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
const record = this.codeRepo.create({
userId,
@@ -208,30 +209,33 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
purpose: VerificationPurpose,
code: string,
): Promise<VerificationCode> {
const record = await this.codeRepo.findOne({
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
});
return this.codeRepo.manager.transaction(async (em) => {
const record = await em.findOne(VerificationCode, {
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
lock: { mode: 'pessimistic_write' },
});
if (!record || record.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('No active code — request a new one');
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
await em.save(record);
throw new BadRequestException('Too many attempts — request a new code');
}
const ok = await bcrypt.compare(code, record.codeHash);
if (!ok) {
record.attempts += 1;
await em.save(record);
throw new BadRequestException('Invalid code');
}
if (!record || record.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('No active code — request a new one');
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
await this.codeRepo.save(record);
throw new BadRequestException('Too many attempts — request a new code');
}
const ok = await bcrypt.compare(code, record.codeHash);
if (!ok) {
record.attempts += 1;
await this.codeRepo.save(record);
throw new BadRequestException('Invalid code');
}
record.consumedAt = new Date();
await this.codeRepo.save(record);
return record;
await em.save(record);
return record;
});
}
/** Best-effort cleanup of long-expired codes (called opportunistically). */