Skip to content
Merged
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
60 changes: 60 additions & 0 deletions apps/api/src/app/events/e2e/trigger-event.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,66 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => {
}
}
});

it('should mask secret environment variables in delivered channel content', async () => {
const secretValue = `sk_live_delivery_exfil_${uuid()}`;
const publicValue = 'https://cdn.example.com/assets';

const createSecretResponse = await session.testAgent.post('/v1/environment-variables').send({
key: 'DELIVERY_STRIPE_SECRET',
isSecret: true,
values: [{ _environmentId: session.environment._id, value: secretValue }],
});
expect(createSecretResponse.status).to.equal(200);

const createPublicResponse = await session.testAgent.post('/v1/environment-variables').send({
key: 'DELIVERY_CDN_URL',
isSecret: false,
values: [{ _environmentId: session.environment._id, value: publicValue }],
});
expect(createPublicResponse.status).to.equal(200);

const workflowBody: CreateWorkflowDto = {
name: 'Secret Env Delivery Mask Workflow',
workflowId: `secret-env-delivery-mask-${uuid()}`,
__source: WorkflowCreationSourceEnum.DASHBOARD,
steps: [
{
type: StepTypeEnum.IN_APP,
name: 'In-App Step',
controlValues: {
subject: 'Secret delivery check',
body: 'secret={{env.DELIVERY_STRIPE_SECRET}} public={{env.DELIVERY_CDN_URL}}',
},
},
],
};

const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody);
expect(workflowResponse.status).to.equal(201);
const v2Workflow = workflowResponse.body.data as WorkflowResponseDto;

await novuClient.trigger({
workflowId: v2Workflow.workflowId,
to: [subscriber.subscriberId],
payload: {},
});

await session.waitForJobCompletion(v2Workflow._id);

const messages = await messageRepository.find({
_environmentId: session.environment._id,
_subscriberId: subscriber._id,
channel: StepTypeEnum.IN_APP,
_templateId: v2Workflow._id,
});
expect(messages.length).to.equal(1);

const content = String(messages[0].content ?? '');
expect(content).to.include(publicValue);
expect(content).to.include(SECRET_MASK);
expect(content).to.not.include(secretValue);
});
});

it('should digest events with filters', async () => {
Expand Down
38 changes: 38 additions & 0 deletions apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ChatProviderIdEnum,
CronExpressionEnum,
RedirectTargetEnum,
SECRET_MASK,
StepTypeEnum,
slugify,
ToolProviderIdEnum,
Expand Down Expand Up @@ -1706,6 +1707,43 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v
});

describe('payload sanitation', () => {
it('should mask secret environment variables in preview output (VULN-082)', async () => {
const secretValue = `sk_live_preview_exfil_${randomUUID()}`;
const publicValue = 'https://cdn.example.com';

const createSecretResponse = await session.testAgent.post('/v1/environment-variables').send({
key: 'PREVIEW_STRIPE_SECRET',
isSecret: true,
values: [{ _environmentId: session.environment._id, value: secretValue }],
});
expect(createSecretResponse.status).to.equal(200);

const createPublicResponse = await session.testAgent.post('/v1/environment-variables').send({
key: 'PREVIEW_CDN_URL',
isSecret: false,
values: [{ _environmentId: session.environment._id, value: publicValue }],
});
expect(createPublicResponse.status).to.equal(200);

const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.SMS);
const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, {
controlValues: {
body: 'secret={{env.PREVIEW_STRIPE_SECRET}} public={{env.PREVIEW_CDN_URL}}',
},
});

expect(previewResponseDto.result!.preview).to.exist;
if (previewResponseDto.result!.type !== 'sms') {
throw new Error('Expected sms');
}

const previewBody = previewResponseDto.result!.preview.body;
expect(previewBody).to.include(publicValue);
expect(previewBody).to.include(SECRET_MASK);
expect(previewBody).to.not.include(secretValue);
expect(JSON.stringify(previewResponseDto)).to.not.include(secretValue);
});

it('Should produce a correct payload when pipe is used etc {{payload.variable | upper}}', async () => {
const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.SMS);
const requestDto = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
assertSafeOutboundUrl,
buildInvalidJsonBodyDetail,
buildNovuSignatureHeader,
enhanceStepsMap,
GetDecryptedSecretKey,
Expand Down Expand Up @@ -66,8 +67,6 @@ export class TestHttpEndpointUsecase {
const method = (compiled.method as string) ?? 'GET';
const compiledHeaders = (compiled.headers as KeyValuePair[]) ?? [];
const compiledBody = compiled.body as string | KeyValuePair[] | undefined;
const resolvedBodyInput =
typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody;

const resolvedHeaders: Record<string, string> = Object.fromEntries(
compiledHeaders.filter(({ key }) => key).map(({ key, value }) => [key, value])
Expand All @@ -77,13 +76,15 @@ export class TestHttpEndpointUsecase {

let resolvedBody: Record<string, unknown> | unknown[] | undefined;
try {
// `repairJsonString` throws on bodies it cannot repair, so it has to stay inside this
// try/catch to return a 400 with the reason instead of an unhandled 500.
const resolvedBodyInput =
typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody;
resolvedBody = resolveHttpRequestBody(resolvedBodyInput);
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : 'Failed to parse raw JSON body';

return {
statusCode: 400,
body: { error: `Invalid raw JSON body: ${errorMessage}` },
body: buildInvalidJsonBodyDetail(parseError, compiledBody),
headers: {},
durationMs: Math.round(performance.now() - startTime),
resolvedRequest: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,16 @@ export const ChatEditor = (props: ChatEditorProps) => {

if (defaultContentActions) {
return (
<TabsSection className="flex min-h-0 flex-1 flex-col p-3">
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex shrink-0 items-center justify-end">{defaultContentActions}</div>
<div className="rounded-12 bg-bg-weak flex min-h-0 flex-1 flex-col gap-2 border border-neutral-100 p-2">
{defaultContent}
<div className="-mx-3 -mt-3 flex h-full min-h-0 flex-col">
<TabsSection className="flex min-h-0 flex-1 flex-col p-3">
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex shrink-0 items-center justify-end">{defaultContentActions}</div>
<div className="rounded-12 bg-bg-weak flex min-h-0 flex-1 flex-col gap-2 border border-neutral-100 p-2">
{defaultContent}
</div>
</div>
</div>
</TabsSection>
</TabsSection>
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { SECRET_MASK } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { ExecuteHttpRequestStep } from './execute-http-request-step.usecase';
import { SendMessageChannelCommand } from './send-message-channel.command';
import { SendMessageStatus } from './send-message-type.usecase';
import { SendMessageResultFailed, SendMessageStatus } from './send-message-type.usecase';

describe('ExecuteHttpRequestStep - steps namespace', () => {
function buildDigestStepsMap() {
Expand Down Expand Up @@ -78,10 +79,18 @@ describe('ExecuteHttpRequestStep - steps namespace', () => {
usecase,
httpClientService,
executeBridgeJob,
createExecutionDetails,
};
}

function buildCommand() {
function findFailureDetail(createExecutionDetails: { execute: sinon.SinonStub }) {
return createExecutionDetails.execute
.getCalls()
.map((call) => call.args[0] as { detail: string; raw?: string })
.find((args) => args.raw?.includes('Invalid raw JSON body'));
}

function buildCommand(env: Record<string, string> = { name: 'Development', type: 'dev' }) {
return SendMessageChannelCommand.create({
environmentId: 'env_1',
organizationId: 'org_1',
Expand Down Expand Up @@ -128,7 +137,7 @@ describe('ExecuteHttpRequestStep - steps namespace', () => {
events: undefined,
total_count: undefined,
},
env: { name: 'Development', type: 'dev' },
env,
} as never,
bridgeData: null,
environment: { _id: 'env_1' } as never,
Expand Down Expand Up @@ -189,4 +198,61 @@ describe('ExecuteHttpRequestStep - steps namespace', () => {
expect(requestArgs.headers['X-Digest-Summary']).to.equal('Ada, Grace');
expect(requestArgs.body).to.deep.equal({ summary: '2 notifications' });
});

it('records an execution detail when the compiled body cannot be repaired into valid JSON', async () => {
const { usecase, httpClientService, createExecutionDetails } = buildUsecase({
url: 'https://example.com/webhook',
method: 'POST',
body: '{"order":{"lines":[{"item":{"sku" }}]}}',
});

const result = (await usecase.execute(buildCommand())) as SendMessageResultFailed;

expect(result.status).to.equal(SendMessageStatus.FAILED);
expect(result.shouldHalt).to.equal(true);
expect(httpClientService.request.called).to.equal(false);

const failureDetail = findFailureDetail(createExecutionDetails);
expect(failureDetail, 'expected a failed execution detail for the unrepairable body').to.not.equal(undefined);

const raw = JSON.parse(failureDetail?.raw ?? '{}');
expect(raw.error).to.contain('Colon expected');
expect(raw.bodyExcerpt).to.contain('"sku"');
expect(raw.hint).to.be.a('string');
});

it('does not halt the chain for an unrepairable body when continueOnFailure is enabled', async () => {
const { usecase, createExecutionDetails } = buildUsecase({
url: 'https://example.com/webhook',
method: 'POST',
continueOnFailure: true,
body: '{"order":{"lines":[{"item":{"sku" }}]}}',
});

const result = (await usecase.execute(buildCommand())) as SendMessageResultFailed;

expect(result.status).to.equal(SendMessageStatus.FAILED);
expect(result.shouldHalt).to.equal(false);
expect(findFailureDetail(createExecutionDetails)).to.not.equal(undefined);
});

it('masks rendered environment variable secrets out of the persisted excerpt', async () => {
const secret = 'sk_live_51NQpZmKq7xTvR3wY';
const { usecase, createExecutionDetails } = buildUsecase({
url: 'https://example.com/webhook',
method: 'POST',
body: '{"token":"{{env.PARTNER_API_KEY}}","tier":"{{env.type}}","order":{"sku" }}',
});

const result = await usecase.execute(buildCommand({ name: 'Production', type: 'prod', PARTNER_API_KEY: secret }));

expect(result.status).to.equal(SendMessageStatus.FAILED);

const raw = JSON.parse(findFailureDetail(createExecutionDetails)?.raw ?? '{}');
expect(raw.bodyExcerpt, 'the excerpt must not carry the decrypted env secret').to.not.contain(secret);
expect(raw.bodyExcerpt).to.contain(SECRET_MASK);
// System env values are not secrets, and masking them would gut the excerpt.
expect(raw.bodyExcerpt).to.contain('"tier":"prod"');
expect(raw.bodyExcerpt).to.contain('"order":{"sku" }');
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import {
assertSafeOutboundUrl,
buildInvalidJsonBodyDetail,
buildNovuSignatureHeader,
CreateExecutionDetails,
CreateExecutionDetailsCommand,
Expand All @@ -25,6 +26,7 @@ import {
ControlValuesLevelEnum,
DeliveryLifecycleDetail,
DeliveryLifecycleStatusEnum,
EnvironmentSystemVariables,
ExecutionDetailsSourceEnum,
ExecutionDetailsStatusEnum,
isOutboundSsrfProtectionEnabled,
Expand Down Expand Up @@ -137,8 +139,6 @@ export class ExecuteHttpRequestStep extends SendMessageType {
const method = (compiled.method as string) ?? 'POST';
const rawHeaders = (compiled.headers as Array<{ key: string; value: string }> | undefined) ?? [];
const compiledBody = compiled.body as string | Array<{ key: string; value: string }> | undefined;
const rawBody =
typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody;
const timeout = (compiled.timeout as number | undefined) ?? 5000;

if (!url) {
Expand Down Expand Up @@ -191,10 +191,12 @@ export class ExecuteHttpRequestStep extends SendMessageType {

let bodyObject: Record<string, unknown> | unknown[] | undefined;
try {
// `repairJsonString` throws on bodies it cannot repair, so it has to stay inside this
// try/catch to surface the failure as an execution detail instead of an unhandled job error.
const rawBody =
typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody;
bodyObject = resolveHttpRequestBody(rawBody);
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : 'Failed to parse raw JSON body';

await this.createExecutionDetails.execute(
CreateExecutionDetailsCommand.create({
...CreateExecutionDetailsCommand.getDetailsFromJob(command.job),
Expand All @@ -203,7 +205,9 @@ export class ExecuteHttpRequestStep extends SendMessageType {
status: ExecutionDetailsStatusEnum.FAILED,
isTest: false,
isRetry: false,
raw: JSON.stringify({ error: `Invalid raw JSON body: ${errorMessage}` }),
raw: JSON.stringify(
buildInvalidJsonBodyDetail(parseError, compiledBody, collectSecretEnvValues(command.compileContext?.env))
),
})
);

Expand Down Expand Up @@ -426,6 +430,26 @@ export class ExecuteHttpRequestStep extends SendMessageType {
}
}

/**
* Compile-safe: adding a field to EnvironmentSystemVariables will cause a TS error here.
*/
const SYSTEM_ENV_KEYS: Record<keyof EnvironmentSystemVariables, true> = { name: true, type: true };

/**
* `env` merges decrypted environment variables, which can hold API keys and tokens, with the
* environment's system variables. Only the user-defined values are treated as secrets: the system
* values are not sensitive, and masking strings as common as `prod` would gut the excerpt.
*/
function collectSecretEnvValues(env: unknown): string[] {
if (!env || typeof env !== 'object') {
return [];
}

return Object.entries(env as Record<string, unknown>)
.filter(([key, value]) => !(key in SYSTEM_ENV_KEYS) && typeof value === 'string' && value.length > 0)
.map(([, value]) => value as string);
}

function getSkipRules(controlValues: Record<string, unknown>): RulesLogic<AdditionalOperation> | undefined {
const skipRules = controlValues.skip as RulesLogic<AdditionalOperation> | undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,8 @@ export class SendMessage {

@Instrument()
private async getEnvironmentVariables(command: SendMessageCommand): Promise<Record<string, string>> {
const cacheKey = `${command.organizationId}:${command.environmentId}`;
const includeSecrets = shouldIncludeEnvironmentSecrets(command.job?.type);
const cacheKey = `${command.organizationId}:${command.environmentId}:${includeSecrets ? 'full' : 'masked'}`;

return this.inMemoryLRUCacheService.get(
InMemoryLRUCacheStore.ENVIRONMENT_VARIABLES,
Expand All @@ -502,7 +503,7 @@ export class SendMessage {
command.environmentId
);

return resolveEnvironmentVariables(rawEnvVars);
return resolveEnvironmentVariables(rawEnvVars, { includeSecrets });
} catch (error) {
Logger.warn(
{ err: error, organizationId: command.organizationId, environmentId: command.environmentId },
Expand Down Expand Up @@ -639,3 +640,12 @@ function requiresBridgeExecution(stepType: StepTypeEnum | undefined): boolean {

return ![StepTypeEnum.TRIGGER, StepTypeEnum.DIGEST, StepTypeEnum.DELAY, StepTypeEnum.HTTP_REQUEST].includes(stepType);
}

/**
* Secret env vars stay masked for channel message rendering (email, SMS, etc.)
* so plaintext never reaches subscribers or the activity UI. Only outbound
* server-side steps that authenticate with those secrets may resolve them.
*/
function shouldIncludeEnvironmentSecrets(stepType: StepTypeEnum | string | undefined): boolean {
return stepType === StepTypeEnum.HTTP_REQUEST || stepType === StepTypeEnum.CUSTOM;
}
Loading
Loading