diff --git a/packages/backend/src/checks/base64-parameter/index.spec.ts b/packages/backend/src/checks/base64-parameter/index.spec.ts new file mode 100644 index 0000000..c1ca04c --- /dev/null +++ b/packages/backend/src/checks/base64-parameter/index.spec.ts @@ -0,0 +1,112 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import base64ParameterCheck from "./index"; + +const runBase64Check = async (config: { + query?: string; + body?: string; +}): Promise => { + const request = createMockRequest({ + id: "req", + host: "example.com", + method: config.body ? "POST" : "GET", + path: "/api", + query: config.query, + body: config.body, + headers: { "Content-Type": ["application/x-www-form-urlencoded"] }, + }); + + const response = createMockResponse({ + id: "res", + code: 200, + headers: { "content-type": ["text/plain"] }, + body: "OK", + }); + + const execution = await runCheck(base64ParameterCheck, [ + { request, response }, + ]); + return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; +}; + +describe("Base64 parameter check", () => { + describe("Detection", () => { + it("should detect base64 in query parameter", async () => { + const findings = await runBase64Check({ + query: "token=YWJjZGVmZ2hpamtsbW5vcA==", + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + name: "Base64 encoded data in parameter", + severity: "low", + }); + }); + + it("should detect base64 in body parameter", async () => { + const findings = await runBase64Check({ + body: "data=SGVsbG9Xb3JsZEJhc2U2NA==", + }); + + expect(findings).toHaveLength(1); + expect(findings[0].description).toContain("body"); + }); + + it("should detect longer base64 strings", async () => { + const findings = await runBase64Check({ + query: "val=YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", + }); + + expect(findings).toHaveLength(1); + }); + + it("should detect multiple base64 parameters", async () => { + const findings = await runBase64Check({ + query: + "tok1=YWJjZGVmZ2hpamtsbW5vcA==&tok2=MTIzNDU2Nzg5MGFiY2RlZg==", + }); + + expect(findings).toHaveLength(1); + expect(findings[0].description).toContain("tok1"); + expect(findings[0].description).toContain("tok2"); + }); + }); + + describe("False Positives", () => { + it("should ignore short strings", async () => { + const findings = await runBase64Check({ query: "token=YWJj" }); + expect(findings).toHaveLength(0); + }); + + it("should ignore non-base64 characters", async () => { + const findings = await runBase64Check({ + query: "val=abcdefghijklmnop!@#$", + }); + expect(findings).toHaveLength(0); + }); + + it("should ignore invalid base64 padding", async () => { + const findings = await runBase64Check({ + query: "val=YWJjZGVmZ2hpamtsbW5vcA=", + }); + expect(findings).toHaveLength(0); + }); + + it("should ignore non-multiple-of-4 length", async () => { + const findings = await runBase64Check({ query: "val=YWJjZGVmZ2hpamtsbW5" }); + expect(findings).toHaveLength(0); + }); + }); + + describe("Edge Cases", () => { + it("should include security guidance", async () => { + const findings = await runBase64Check({ + query: "token=YWJjZGVmZ2hpamtsbW5vcA==", + }); + + expect(findings[0].description).toContain("sensitive information"); + expect(findings[0].description).toContain("encrypt"); + }); + }); +}); diff --git a/packages/backend/src/checks/base64-parameter/index.ts b/packages/backend/src/checks/base64-parameter/index.ts new file mode 100644 index 0000000..d001444 --- /dev/null +++ b/packages/backend/src/checks/base64-parameter/index.ts @@ -0,0 +1,80 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { extractParameters, type Parameter } from "../../utils"; +import { keyStrategy } from "../../utils/key"; + +const MIN_LENGTH = 16; +const BASE64_REGEX = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; + +const looksLikeBase64 = (value: string): boolean => { + if (value.length < MIN_LENGTH) { + return false; + } + + if (value.length % 4 !== 0) { + return false; + } + + return BASE64_REGEX.test(value); +}; + +const describeParameter = (param: Parameter): string => { + return `- Parameter \`${param.name}\` from ${param.source} appears to contain Base64 encoded data`; +}; + +export default defineCheck(({ step }) => { + step("detectBase64", (state, context) => { + const params = extractParameters(context); + if (params.length === 0) { + return done({ state }); + } + + const matches = params.filter((param) => looksLikeBase64(param.value)); + if (matches.length === 0) { + return done({ state }); + } + + const details = matches.map(describeParameter).join("\n"); + + const description = [ + "One or more parameters look like Base64-encoded data, which can hide sensitive information or payloads from cursory inspection.", + "", + details, + "", + "**Recommendation:** Review whether Base64 encoding is required. Consider alternative transport mechanisms (cookies, headers) or encrypt sensitive data.", + ].join("\n"); + + return done({ + state, + findings: [ + { + name: "Base64 encoded data in parameter", + description, + severity: Severity.LOW, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "base64-parameter", + name: "Base64 encoded data in parameter", + description: + "Detects parameters that appear to contain Base64-encoded values.", + type: "passive", + tags: [Tags.SENSITIVE_DATA, Tags.INPUT_VALIDATION], + severities: [Severity.LOW], + aggressivity: { minRequests: 0, maxRequests: 0 }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPort().withPath().build(), + when: () => true, + }; +}); diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..1569b04 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -1,5 +1,6 @@ import antiClickjackingScan from "./anti-clickjacking"; import applicationErrorsScan from "./application-errors"; +import base64ParameterScan from "./base64-parameter"; import bigRedirectsScan from "./big-redirects"; import commandInjectionScan from "./command-injection"; import corsMisconfigScan from "./cors-misconfig"; @@ -71,6 +72,7 @@ export const Checks = { SQL_STATEMENT_IN_PARAMS: "sql-statement-in-params", SSN_DISCLOSURE: "ssn-disclosure", SUSPECT_TRANSFORM: "suspect-transform", + BASE64_PARAMETER: "base64-parameter", // MYSQL_TIME_BASED_SQLI: "mysql-time-based-sqli" - TODO: fix false positives } as const; @@ -111,5 +113,6 @@ export const checks = [ sqlStatementInParams, ssnDisclosureScan, suspectTransformScan, + base64ParameterScan, // mysqlTimeBased, ] as const; diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..7855566 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.BASE64_PARAMETER, + enabled: false, + }, ], }, { @@ -371,6 +375,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.BASE64_PARAMETER, + enabled: true, + }, ], }, {