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
2 changes: 2 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import 'dotenv/config';
import { normalizeLogLevel } from './logger';

const DEFAULT_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';

Check failure on line 4 in backend/src/config.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'DEFAULT_NETWORK_PASSPHRASE' is assigned a value but never used

Check failure on line 4 in backend/src/config.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'DEFAULT_NETWORK_PASSPHRASE' is assigned a value but never used

const parseOrigins = (originsStr: string): string[] => {

Check failure on line 6 in backend/src/config.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'parseOrigins' is assigned a value but never used

Check failure on line 6 in backend/src/config.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'parseOrigins' is assigned a value but never used
return originsStr
.split(',')
.map((value) => value.trim())
Expand Down Expand Up @@ -45,6 +45,8 @@
),
keepAliveTimeoutMs: parseInteger(process.env.KEEP_ALIVE_TIMEOUT_MS, 65_000),
headersTimeoutMs: parseInteger(process.env.HEADERS_TIMEOUT_MS, 66_000),
contractId: process.env.CONTRACT_ID ?? "",
sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org:443",
};

export const walletIntegrationReady = Boolean(
Expand Down
45 changes: 28 additions & 17 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
reconcileOnChainPledge,
refundContributor,
SortOrder,
updateCampaign,

Check failure on line 37 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'updateCampaign' is defined but never used

Check failure on line 37 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'updateCampaign' is defined but never used
} from './services/campaignStore';
import { checkDbHealth } from './services/db';
import { listCampaignHistory } from './services/eventHistory';
Expand Down Expand Up @@ -62,9 +62,9 @@

const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed'];
const CONTRACT_AMOUNT_DECIMALS = Number(process.env.CONTRACT_AMOUNT_DECIMALS ?? 2);
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 120;
const WRITE_RATE_LIMIT_MAX_REQUESTS = 40;
const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120);
const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20);
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate env limits before using them.

Number(...) accepts invalid config as NaN or 0, which can make headers emit NaN and effectively bypass throttling. Parse positive safe integers and fall back when invalid.

Suggested fix
-const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
-const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120);
-const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20);
+function parsePositiveInt(value: string | undefined, fallback: number): number {
+  const parsed = Number(value);
+  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+const RATE_LIMIT_WINDOW_MS = parsePositiveInt(process.env.RATE_LIMIT_WINDOW_MS, 60000);
+const RATE_LIMIT_MAX_REQUESTS = parsePositiveInt(
+  process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS,
+  120,
+);
+const WRITE_RATE_LIMIT_MAX_REQUESTS = parsePositiveInt(
+  process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS,
+  20,
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120);
const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20);
function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
const RATE_LIMIT_WINDOW_MS = parsePositiveInt(process.env.RATE_LIMIT_WINDOW_MS, 60000);
const RATE_LIMIT_MAX_REQUESTS = parsePositiveInt(
process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS,
120,
);
const WRITE_RATE_LIMIT_MAX_REQUESTS = parsePositiveInt(
process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS,
20,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.ts` around lines 65 - 67, The rate limit constants in
index.ts are currently derived with Number(...), which can turn invalid env
values into NaN or 0 and break throttling. Update the parsing for
RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_REQUESTS, and WRITE_RATE_LIMIT_MAX_REQUESTS
to validate that the env values are positive safe integers, and fall back to the
existing defaults when they are missing or invalid. Keep the fix localized to
the rate-limit setup near the RATE_LIMIT_* constants so the downstream header
and limiter logic always receives valid numbers.

const CAMPAIGN_DETAIL_PLEDGE_PREVIEW_LIMIT = 5;

app.use(
Expand Down Expand Up @@ -103,33 +103,44 @@

const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();

function applyRateLimit(maxRequests: number) {
export function applyRateLimit(limitOverride?: number) {
return (req: Request, res: Response, next: express.NextFunction) => {
const key = `${req.ip}:${req.path}:${maxRequests}`;
if ((req as any).rateLimitedProcessed) {

Check failure on line 108 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

Unexpected any. Specify a different type

Check failure on line 108 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

Unexpected any. Specify a different type
return next();
}
(req as any).rateLimitedProcessed = true;

Check failure on line 111 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

Unexpected any. Specify a different type

Check failure on line 111 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

Unexpected any. Specify a different type

const isWrite = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
const maxRequests = limitOverride ?? (isWrite ? WRITE_RATE_LIMIT_MAX_REQUESTS : RATE_LIMIT_MAX_REQUESTS);

const key = `${req.ip}:${isWrite ? "write" : "read"}`;
const now = Date.now();
const current = rateLimitBuckets.get(key);

if (!current || now >= current.resetAt) {
rateLimitBuckets.set(key, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return next();
let count = 1;
let resetAt = now + RATE_LIMIT_WINDOW_MS;

if (current && now < current.resetAt) {
count = current.count + 1;
resetAt = current.resetAt;
}

if (current.count >= maxRequests) {
res.setHeader("X-RateLimit-Limit", String(maxRequests));
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));

if (current && now < current.resetAt && current.count >= maxRequests) {
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
res.setHeader('Retry-After', String(retryAfterSec));
throw new AppError('Rate limit exceeded. Please retry shortly.', 429, 'RATE_LIMITED');
res.setHeader("Retry-After", String(retryAfterSec));
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
}

current.count += 1;
rateLimitBuckets.set(key, current);
rateLimitBuckets.set(key, { count, resetAt });
Comment on lines 117 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prune expired buckets to avoid unbounded memory growth.

Each distinct IP/type key remains in rateLimitBuckets forever unless the same key returns. A botnet or spoofed-proxy scenario can grow this map without bound.

Suggested fix
+let lastRateLimitSweep = 0;
+
+function sweepExpiredRateLimitBuckets(now: number): void {
+  if (now - lastRateLimitSweep < RATE_LIMIT_WINDOW_MS) return;
+  lastRateLimitSweep = now;
+
+  for (const [bucketKey, bucket] of rateLimitBuckets.entries()) {
+    if (now >= bucket.resetAt) {
+      rateLimitBuckets.delete(bucketKey);
+    }
+  }
+}
+
     const key = `${req.ip}:${isWrite ? "write" : "read"}`;
     const now = Date.now();
+    sweepExpiredRateLimitBuckets(now);
     const current = rateLimitBuckets.get(key);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const now = Date.now();
const current = rateLimitBuckets.get(key);
if (!current || now >= current.resetAt) {
rateLimitBuckets.set(key, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return next();
let count = 1;
let resetAt = now + RATE_LIMIT_WINDOW_MS;
if (current && now < current.resetAt) {
count = current.count + 1;
resetAt = current.resetAt;
}
if (current.count >= maxRequests) {
res.setHeader("X-RateLimit-Limit", String(maxRequests));
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
if (current && now < current.resetAt && current.count >= maxRequests) {
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
res.setHeader('Retry-After', String(retryAfterSec));
throw new AppError('Rate limit exceeded. Please retry shortly.', 429, 'RATE_LIMITED');
res.setHeader("Retry-After", String(retryAfterSec));
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
}
current.count += 1;
rateLimitBuckets.set(key, current);
rateLimitBuckets.set(key, { count, resetAt });
let lastRateLimitSweep = 0;
function sweepExpiredRateLimitBuckets(now: number): void {
if (now - lastRateLimitSweep < RATE_LIMIT_WINDOW_MS) return;
lastRateLimitSweep = now;
for (const [bucketKey, bucket] of rateLimitBuckets.entries()) {
if (now >= bucket.resetAt) {
rateLimitBuckets.delete(bucketKey);
}
}
}
const key = `${req.ip}:${isWrite ? "write" : "read"}`;
const now = Date.now();
sweepExpiredRateLimitBuckets(now);
const current = rateLimitBuckets.get(key);
let count = 1;
let resetAt = now + RATE_LIMIT_WINDOW_MS;
if (current && now < current.resetAt) {
count = current.count + 1;
resetAt = current.resetAt;
}
res.setHeader("X-RateLimit-Limit", String(maxRequests));
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
if (current && now < current.resetAt && current.count >= maxRequests) {
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
res.setHeader("Retry-After", String(retryAfterSec));
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
}
rateLimitBuckets.set(key, { count, resetAt });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.ts` around lines 117 - 138, The rate limiting logic in the
request handler leaves expired entries in rateLimitBuckets indefinitely, which
can cause unbounded memory growth. Update the rate-limit path around the
existing current/resetAt handling to remove buckets whose resetAt has passed
before computing headers or updating the count, and consider pruning the key on
each request when the window has expired so stale IP/type entries do not
accumulate.

return next();
};
}

app.use(applyRateLimit(RATE_LIMIT_MAX_REQUESTS));
app.use(applyRateLimit());

app.use(requestIdMiddleware);

Expand Down Expand Up @@ -596,7 +607,7 @@
}
});

app.use((err: any, req: Request, res: Response, _next: express.NextFunction) => {

Check failure on line 610 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'_next' is defined but never used

Check failure on line 610 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

Unexpected any. Specify a different type

Check failure on line 610 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'_next' is defined but never used

Check failure on line 610 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

Unexpected any. Specify a different type
if (err.type === 'entity.too.large') {
return res.status(413).json({
success: false,
Expand Down
69 changes: 69 additions & 0 deletions backend/src/rateLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { applyRateLimit } from "./index";
import { Request, Response } from "express";

describe("Rate Limiter Middleware", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let nextCalled: boolean;
let headers: Record<string, string>;

beforeEach(() => {
nextCalled = false;
headers = {};
mockReq = {
ip: "127.0.0.1",
method: "GET",
};
Comment on lines +11 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use fresh request objects for each simulated HTTP request.

applyRateLimit sets rateLimitedProcessed on the request, so the second and third calls reuse the bypass flag and never increment the bucket. Also use a unique IP per test to avoid leaking module-level bucket state between cases.

Suggested fix
 describe("Rate Limiter Middleware", () => {
   let mockReq: Partial<Request>;
   let mockRes: Partial<Response>;
   let nextCalled: boolean;
   let headers: Record<string, string>;
+  let testIpCounter = 0;
+  let testIp: string;
 
   beforeEach(() => {
     nextCalled = false;
     headers = {};
+    testIp = `127.0.0.${++testIpCounter}`;
     mockReq = {
-      ip: "127.0.0.1",
+      ip: testIp,
       method: "GET",
     };
@@
   it("should enforce rate limiting and throw 429 when limit is exceeded", () => {
-    mockReq.method = "POST";
+    const makePostReq = () => ({ ip: testIp, method: "POST" }) as Request;
     const middleware = applyRateLimit(2); // Set limit to 2 for testing
 
     // First request
-    middleware(mockReq as Request, mockRes as Response, next);
+    middleware(makePostReq(), mockRes as Response, next);
     expect(nextCalled).toBe(true);
 
     // Second request
     nextCalled = false;
-    middleware(mockReq as Request, mockRes as Response, next);
+    middleware(makePostReq(), mockRes as Response, next);
     expect(nextCalled).toBe(true);
 
     // Third request - should exceed limit
     nextCalled = false;
     expect(() => {
-      middleware(mockReq as Request, mockRes as Response, next);
+      middleware(makePostReq(), mockRes as Response, next);
     }).toThrow(/Rate limit exceeded/);

Also applies to: 53-66

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/rateLimiter.test.ts` around lines 11 - 17, The rate limiter tests
are reusing the same request object and IP across calls, which causes
applyRateLimit to carry over the rateLimitedProcessed bypass flag and shared
bucket state. Update the test setup around mockReq and the repeated
applyRateLimit calls to create a fresh request object for each simulated HTTP
request, and use a unique ip per test/case so state does not leak between
assertions. Reference the applyRateLimit test helper usage and the beforeEach
mockReq initialization when making the change.

mockRes = {
setHeader: vi.fn((key: string, value: string) => {
headers[key] = value;
return mockRes as Response;
}),
};
});

const next = () => {
nextCalled = true;
};

it("should set X-RateLimit headers for GET requests (Read limits)", () => {
const middleware = applyRateLimit();
middleware(mockReq as Request, mockRes as Response, next);

expect(nextCalled).toBe(true);
expect(headers["X-RateLimit-Limit"]).toBe("120");
expect(headers["X-RateLimit-Remaining"]).toBeDefined();
expect(headers["X-RateLimit-Reset"]).toBeDefined();
});

it("should set X-RateLimit headers for POST requests (Write limits)", () => {
mockReq.method = "POST";
const middleware = applyRateLimit();
middleware(mockReq as Request, mockRes as Response, next);

expect(nextCalled).toBe(true);
expect(headers["X-RateLimit-Limit"]).toBe("20");
});

it("should enforce rate limiting and throw 429 when limit is exceeded", () => {
mockReq.method = "POST";
const middleware = applyRateLimit(2); // Set limit to 2 for testing

// First request
middleware(mockReq as Request, mockRes as Response, next);
expect(nextCalled).toBe(true);

// Second request
nextCalled = false;
middleware(mockReq as Request, mockRes as Response, next);
expect(nextCalled).toBe(true);

// Third request - should exceed limit
nextCalled = false;
expect(() => {
middleware(mockReq as Request, mockRes as Response, next);
}).toThrow(/Rate limit exceeded/);
expect(headers["Retry-After"]).toBeDefined();
});
});
80 changes: 80 additions & 0 deletions backend/src/validation/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { createCampaignPayloadSchema } from "./schemas";

describe("createCampaignPayloadSchema - Input Sanitization", () => {
const basePayload = {
creator: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
acceptedTokens: ["USDC"],
targetAmount: 100,
deadline: Math.floor(Date.now() / 1000) + 3600,
};

it("should successfully validate and trim valid inputs", () => {
const result = createCampaignPayloadSchema.safeParse({
...basePayload,
title: " Valid Campaign Title ",
description: " This is a valid campaign description that meets the length requirements. ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.title).toBe("Valid Campaign Title");
expect(result.data.description).toBe("This is a valid campaign description that meets the length requirements.");
}
});

it("should reject titles with only whitespace", () => {
const result = createCampaignPayloadSchema.safeParse({
...basePayload,
title: " ",
description: "This is a valid campaign description that meets the length requirements.",
});
expect(result.success).toBe(false);
});

it("should escape HTML tags in title and description during parsing", () => {
const result = createCampaignPayloadSchema.safeParse({
...basePayload,
title: "<h1>Test</h1>",
description: "<h1>Test</h1> with at least 20 characters",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.title).toBe("&lt;h1&gt;Test&lt;&sol;h1&gt;");
expect(result.data.description).toBe("&lt;h1&gt;Test&lt;&sol;h1&gt; with at least 20 characters");
}
});

it("should reject script tags in title", () => {
const result = createCampaignPayloadSchema.safeParse({
...basePayload,
title: "Campaign <script>alert(1)</script>",
description: "This is a valid campaign description that meets the length requirements.",
});
expect(result.success).toBe(false);
});

it("should reject SQL comment sequences in title", () => {
const result1 = createCampaignPayloadSchema.safeParse({
...basePayload,
title: "Campaign -- SQL Injection",
description: "This is a valid campaign description that meets the length requirements.",
});
expect(result1.success).toBe(false);

const result2 = createCampaignPayloadSchema.safeParse({
...basePayload,
title: "Campaign /* SQL Comment */",
description: "This is a valid campaign description that meets the length requirements.",
});
expect(result2.success).toBe(false);
});

it("should reject SQL comment sequences in description", () => {
const result = createCampaignPayloadSchema.safeParse({
...basePayload,
title: "Valid Campaign",
description: "This is a valid campaign description that meets the length requirements. -- SQL injection",
});
expect(result.success).toBe(false);
});
});
25 changes: 23 additions & 2 deletions backend/src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,35 @@ export const unixTimestampSchema = z.coerce
.int("deadline must be a valid UNIX timestamp in seconds.")
.positive("deadline must be a valid UNIX timestamp in seconds.");

function sanitizeInput(val: string): string {
return val
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\//g, "&sol;");
}
Comment on lines +48 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical

Incomplete HTML escaping creates an entity-based XSS bypass

sanitizeInput escapes <, >, and / but fails to escape &. Consequently, entity-encoded payloads like &amp;#60;script&amp;#62; bypass the containsScriptTag check (which only matches literal <script) and are persisted unescaped. When rendered, &amp; decodes back to <, allowing script execution.

Additionally:

  1. The &amp; replacement must occur before escaping other entities to prevent double-encoding issues.
  2. Quotes (", ') are also missing for attribute context safety.
  3. Updates to schemas.test.ts (Lines 42-43) are required to reflect the corrected output.
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 48-50: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: val
.replace(/</g, "<")
.replace(/>/g, ">")
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)


[warning] 48-49: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: val
.replace(/</g, "<")
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/validation/schemas.ts` around lines 48 - 53, The sanitizeInput
helper currently misses entity escaping for ampersands and quotes, which lets
entity-encoded payloads bypass containsScriptTag and later render unsafely.
Update sanitizeInput to escape "&" first, then the other special characters, and
include escaping for both quotation marks; keep the change centered on
sanitizeInput so the existing validation flow still works. Also adjust the
corresponding expectations in schemas.test.ts to match the new escaped output.


const containsSqlComment = (val: string) => /--|\/\*|\*\//.test(val);
const containsScriptTag = (val: string) => /<script/i.test(val);

export const createCampaignPayloadSchema = z.object({
creator: stellarAccountIdSchema,
title: z.string().trim().min(4, "Title must be at least 4 characters.").max(80),
title: z
.string()
.trim()
.min(4, "Title must be at least 4 characters.")
.max(80)
.refine((val) => val.trim().length >= 4, "Title cannot be only whitespace.")
.refine((val) => !containsScriptTag(val), "Title cannot contain script tags.")
.refine((val) => !containsSqlComment(val), "Title cannot contain SQL comment sequences.")
.transform((val) => sanitizeInput(val)),
description: z
.string()
.trim()
.min(20, "Description must be at least 20 characters.")
.max(500),
.max(500)
.refine((val) => !containsScriptTag(val), "Description cannot contain script tags.")
.refine((val) => !containsSqlComment(val), "Description cannot contain SQL comment sequences.")
.transform((val) => sanitizeInput(val)),
acceptedTokens: z
.array(assetCodeSchema)
.min(1, "At least one accepted token is required."),
Expand Down
Loading