1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-27162] Add runtime type guards for decrypted JSON data (#16996)

* Add runtime type guards for decrypted JSON data

- Create risk-insights-type-guards.ts with validation functions
- Replace unsafe type assertions with runtime validation in encryption service
- Validate ApplicationHealthReportDetail, OrganizationReportSummary, and OrganizationReportApplication
- Add detailed error messages for validation failures
- Remove TODO comments for type guard implementation

Improves security by preventing malformed data from bypassing type safety and ensures data integrity for decrypted report structures.

* test file fix

* date validation

* add runtime type guards and validation failure tests

Issue 1: Missing Test Coverage for Type Guard Validation Failures
- Create comprehensive test suite with 17 tests covering all validation scenarios
- Test invalid structures, missing fields, wrong types, and edge cases
- Verify proper error messages and validation logic for all data types

Issue 2: Silent Failure on Validation Errors (Security Concern)
- Re-throw validation errors instead of silently returning empty/default data
- Add descriptive error messages indicating potential data corruption or tampering
- Ensure all validation failures are surfaced as security issues, not swallowed

Additional Fix: Date Validation Vulnerability
- Validate date strings before creating Date objects to prevent Invalid Date (NaN)
- Throw explicit errors for unparseable date strings
- Update error handling to catch and properly surface date validation failures

* add empty string validation and sanitize error messages

- Validate array elements are non-empty strings (atRiskCipherIds, cipherIds, newApplications)
- Sanitize validation error messages to prevent information disclosure
- Log detailed errors for debugging, re-throw generic messages
- Add tests for empty string validation and error message sanitization

* add comprehensive validation for scalar strings and numeric ranges

- Validate all scalar string fields are non-empty (applicationName, userName, email, cipherId, userGuid)
- Add numeric range validation (finite, non-negative) for all count fields
- Export type guard functions for testability and reusability
- Add 19 new tests covering edge cases (empty strings, NaN, Infinity, negative numbers)

* prevent prototype pollution and unexpected property injection in type guards

- Validate object prototype is Object.prototype (prevents __proto__ attacks)
- Check for dangerous own properties (constructor, prototype)
- Strict property enumeration - reject objects with unexpected properties
- Add comprehensive security tests (prototype pollution, unexpected props)
- Protects against data tampering and information leakage

* security: always sanitize error messages to prevent information disclosure

- Remove fragile pattern matching in error handlers
- Always throw generic error messages by default
- Log detailed errors for debugging, never expose to callers
- Future-proof against validation error message changes
- Prevents disclosure of internal data structure details

Applies to all decryption/validation methods in encryption service

* security: comprehensive hardening of type validation system

CRITICAL FIXES:
- Add __proto__ to prototype pollution checks (loop-based)
- Remove conditional error sanitization (always sanitize)

SECURITY ENHANCEMENTS:
- Add integer overflow protection (Number.isSafeInteger)
- Add DoS prevention (array/string length limits: 50K/1K)
- Strengthen all 4 type guards with 10-layer validation

LIMITS:
- Max string length: 1,000 characters
- Max array length: 50,000 elements
- Max safe integer: 2^53 - 1

DOCUMENTATION:
- Update code-review-methodology.md with patterns
- Update .cursorrules with security best practices
- Create comprehensive security audit document

All 57 tests passing. No linting errors.
Defense-in-depth complete - production ready.

* fix: consolidate security constants and add upper bound validation

CRITICAL FIXES:
- Consolidate MAX_STRING_LENGTH and MAX_ARRAY_LENGTH to file level (DRY)
- Add MAX_COUNT constant (10M) for upper bound validation
- Apply upper bound checks to all 12 count fields

BENEFITS:
- Single source of truth for security limits
- Prevents business logic issues from extreme values
- Easier maintenance and updates
This commit is contained in:
Alex
2025-10-28 11:03:11 -04:00
committed by GitHub
parent 6f34b6098a
commit 6505ce05db
4 changed files with 1232 additions and 31 deletions

View File

@@ -151,14 +151,17 @@ describe("RiskInsightsEncryptionService", () => {
describe("decryptRiskInsightsReport", () => {
it("should decrypt data and return original object", async () => {
// Arrange: setup our mocks
// Arrange: setup our mocks with valid data structures
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
// act: call the decrypt method - with any params
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
// Mock decryption to return valid data for each call
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData))
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
// act: call the decrypt method
const result = await service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
@@ -169,33 +172,37 @@ describe("RiskInsightsEncryptionService", () => {
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey);
expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3);
// Mock decrypt returns JSON.stringify(testData)
// Verify decrypted data matches the mocked valid data
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
});
});
it("should invoke data type validation method during decryption", async () => {
// Arrange: setup our mocks
// Arrange: setup our mocks with valid data structures
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
// act: call the decrypt method - with any params
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
// Mock decryption to return valid data for each call
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData))
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
// act: call the decrypt method
const result = await service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
);
// Verify that validation passed and returned the correct data
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
});
});
@@ -211,7 +218,7 @@ describe("RiskInsightsEncryptionService", () => {
).rejects.toEqual(Error("Organization key not found"));
});
it("should return null if decrypt throws", async () => {
it("should throw if decrypt throws", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail"));
@@ -224,5 +231,106 @@ describe("RiskInsightsEncryptionService", () => {
),
).rejects.toEqual(Error("fail"));
});
it("should throw error when report data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Mock decryption to return invalid data
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify([{ invalid: "data" }])) // invalid report data
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Report data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error when summary data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report data should succeed, summary should fail
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify({ invalid: "summary" })) // invalid summary data - fails here
.mockResolvedValueOnce(JSON.stringify(mockApplicationData)); // won't be called but prevents fallback
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Summary data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error when application data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report and summary should succeed, application should fail
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
.mockResolvedValueOnce(JSON.stringify([{ invalid: "application" }])); // invalid app data
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Application data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error for invalid date in application data", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
const invalidApplicationData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "invalid-date-string",
},
];
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report and summary succeed, application with invalid date fails
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
.mockResolvedValueOnce(JSON.stringify(invalidApplicationData)); // invalid date
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Application data validation failed.*This may indicate data corruption or tampering/,
);
});
});
});

View File

@@ -10,14 +10,20 @@ import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import {
DecryptedReportData,
EncryptedReportData,
EncryptedDataWithKey,
ApplicationHealthReportDetail,
OrganizationReportSummary,
DecryptedReportData,
EncryptedDataWithKey,
EncryptedReportData,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
import {
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "./risk-insights-type-guards";
export class RiskInsightsEncryptionService {
constructor(
private keyService: KeyService,
@@ -182,11 +188,16 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as ApplicationHealthReportDetail[];
// Validate parsed data structure with runtime type guards
return validateApplicationHealthReportDetailArray(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error);
return [];
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Report data validation failed. This may indicate data corruption or tampering.",
);
}
}
@@ -202,14 +213,19 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportSummary;
// Validate parsed data structure with runtime type guards
return validateOrganizationReportSummary(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report summary",
error,
);
return createNewSummaryData();
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Summary data validation failed. This may indicate data corruption or tampering.",
);
}
}
@@ -225,14 +241,19 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportApplication[];
// Validate parsed data structure with runtime type guards
return validateOrganizationReportApplicationArray(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report applications",
error,
);
return [];
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Application data validation failed. This may indicate data corruption or tampering.",
);
}
}
}

View File

@@ -0,0 +1,668 @@
import { MemberDetails } from "../../models";
import {
isApplicationHealthReportDetail,
isMemberDetails,
isOrganizationReportApplication,
isOrganizationReportSummary,
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "./risk-insights-type-guards";
describe("Risk Insights Type Guards", () => {
describe("validateApplicationHealthReportDetailArray", () => {
it("should validate valid ApplicationHealthReportDetail array", () => {
const validData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1", "cipher-2"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [
{
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
},
],
atRiskMemberDetails: [
{
userGuid: "user-2",
userName: "Jane Doe",
email: "jane@example.com",
cipherId: "cipher-2",
},
],
cipherIds: ["cipher-1", "cipher-2"],
},
];
expect(() => validateApplicationHealthReportDetailArray(validData)).not.toThrow();
expect(validateApplicationHealthReportDetailArray(validData)).toEqual(validData);
});
it("should throw error for non-array input", () => {
expect(() => validateApplicationHealthReportDetailArray("not an array")).toThrow(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
});
it("should throw error for array with invalid elements", () => {
const invalidData = [
{
applicationName: "Test App",
// missing required fields
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/,
);
});
it("should throw error for array with multiple invalid elements", () => {
const invalidData = [
{ applicationName: "App 1" }, // invalid
{
applicationName: "App 2",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
}, // valid
{ applicationName: "App 3" }, // invalid
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/,
);
});
it("should throw error for invalid memberDetails", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
it("should throw error for empty string in atRiskCipherIds", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1", "", "cipher-3"], // empty string
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
it("should throw error for empty string in cipherIds", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["", "cipher-2"], // empty string
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
});
describe("validateOrganizationReportSummary", () => {
it("should validate valid OrganizationReportSummary", () => {
const validData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1", "app-2"],
};
expect(() => validateOrganizationReportSummary(validData)).not.toThrow();
expect(validateOrganizationReportSummary(validData)).toEqual(validData);
});
it("should throw error for missing totalMemberCount", () => {
const invalidData = {
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields: totalMemberCount \(number\)/,
);
});
it("should throw error for multiple missing fields", () => {
const invalidData = {
totalMemberCount: 10,
// missing multiple fields
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields:.*totalApplicationCount/,
);
});
it("should throw error for invalid field types", () => {
const invalidData = {
totalMemberCount: "10", // should be number
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary/,
);
});
it("should throw error for non-array newApplications", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: "not-an-array",
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary.*newApplications/,
);
});
it("should throw error for empty string in newApplications", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1", "", "app-3"], // empty string
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary/,
);
});
});
describe("validateOrganizationReportApplicationArray", () => {
it("should validate valid OrganizationReportApplication array", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: null,
},
{
applicationName: "Another App",
isCritical: false,
reviewedDate: new Date("2024-01-01"),
},
];
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].applicationName).toBe("Test App");
expect(result[1].reviewedDate).toBeInstanceOf(Date);
});
it("should convert string dates to Date objects", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "2024-01-01T00:00:00.000Z",
},
];
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].reviewedDate).toBeInstanceOf(Date);
expect(result[0].reviewedDate?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
});
it("should throw error for invalid date strings", () => {
const invalidData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "invalid-date",
},
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
"Invalid date string: invalid-date",
);
});
it("should throw error for non-array input", () => {
expect(() => validateOrganizationReportApplicationArray("not an array")).toThrow(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
});
it("should throw error for array with invalid elements", () => {
const invalidData = [
{
applicationName: "Test App",
reviewedDate: null as any,
// missing isCritical field
} as any,
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/,
);
});
it("should throw error for invalid field types", () => {
const invalidData = [
{
applicationName: 123 as any, // should be string
isCritical: true,
reviewedDate: null as any,
} as any,
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
/Invalid application data/,
);
});
it("should accept null reviewedDate", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: null as any,
},
];
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].reviewedDate).toBeNull();
});
});
// Tests for exported type guard functions
describe("isMemberDetails", () => {
it("should return true for valid MemberDetails", () => {
const validData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(validData)).toBe(true);
});
it("should return false for empty userGuid", () => {
const invalidData = {
userGuid: "",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty userName", () => {
const invalidData = {
userGuid: "user-1",
userName: "",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty email", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty cipherId", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
unexpectedProperty: "should fail",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
__proto__: { malicious: "payload" },
};
expect(isMemberDetails(invalidData)).toBe(false);
});
});
describe("isApplicationHealthReportDetail", () => {
it("should return true for valid ApplicationHealthReportDetail", () => {
const validData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(validData)).toBe(true);
});
it("should return false for empty applicationName", () => {
const invalidData = {
applicationName: "",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for NaN passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: NaN,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for Infinity passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: Infinity,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for negative passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: -5,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for negative memberCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: -1,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
injectedProperty: "malicious",
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
});
describe("isOrganizationReportSummary", () => {
it("should return true for valid OrganizationReportSummary", () => {
const validData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(validData)).toBe(true);
});
it("should return false for NaN totalMemberCount", () => {
const invalidData = {
totalMemberCount: NaN,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for Infinity totalApplicationCount", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: Infinity,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for negative totalAtRiskMemberCount", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: -1,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
extraField: "should be rejected",
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
});
describe("isOrganizationReportApplication", () => {
it("should return true for valid OrganizationReportApplication", () => {
const validData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return false for empty applicationName", () => {
const invalidData = {
applicationName: "",
isCritical: true,
reviewedDate: null as Date | null,
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
it("should return true for Date reviewedDate", () => {
const validData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: new Date(),
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return true for string reviewedDate", () => {
const validData = {
applicationName: "Test App",
isCritical: false,
reviewedDate: "2024-01-01",
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
injectedProperty: "malicious",
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts via __proto__", () => {
const invalidData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
__proto__: { polluted: true },
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
});
});

View File

@@ -0,0 +1,404 @@
import {
ApplicationHealthReportDetail,
MemberDetails,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
/**
* Security limits for validation (prevent DoS attacks and ensure reasonable data sizes)
*/
const MAX_STRING_LENGTH = 1000; // Reasonable limit for names, emails, GUIDs
const MAX_ARRAY_LENGTH = 50000; // Reasonable limit for report arrays
const MAX_COUNT = 10000000; // 10 million - reasonable upper bound for count fields
/**
* Type guard to validate MemberDetails structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isMemberDetails(obj: any): obj is MemberDetails {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["userGuid", "userName", "email", "cipherId"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.userGuid === "string" &&
obj.userGuid.length > 0 &&
obj.userGuid.length <= MAX_STRING_LENGTH &&
typeof obj.userName === "string" &&
obj.userName.length > 0 &&
obj.userName.length <= MAX_STRING_LENGTH &&
typeof obj.email === "string" &&
obj.email.length > 0 &&
obj.email.length <= MAX_STRING_LENGTH &&
typeof obj.cipherId === "string" &&
obj.cipherId.length > 0 &&
obj.cipherId.length <= MAX_STRING_LENGTH
);
}
/**
* Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isApplicationHealthReportDetail(obj: any): obj is ApplicationHealthReportDetail {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"applicationName",
"passwordCount",
"atRiskPasswordCount",
"atRiskCipherIds",
"memberCount",
"atRiskMemberCount",
"memberDetails",
"atRiskMemberDetails",
"cipherIds",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.passwordCount === "number" &&
Number.isFinite(obj.passwordCount) &&
Number.isSafeInteger(obj.passwordCount) &&
obj.passwordCount >= 0 &&
obj.passwordCount <= MAX_COUNT &&
typeof obj.atRiskPasswordCount === "number" &&
Number.isFinite(obj.atRiskPasswordCount) &&
Number.isSafeInteger(obj.atRiskPasswordCount) &&
obj.atRiskPasswordCount >= 0 &&
obj.atRiskPasswordCount <= MAX_COUNT &&
Array.isArray(obj.atRiskCipherIds) &&
obj.atRiskCipherIds.length <= MAX_ARRAY_LENGTH &&
obj.atRiskCipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
) &&
typeof obj.memberCount === "number" &&
Number.isFinite(obj.memberCount) &&
Number.isSafeInteger(obj.memberCount) &&
obj.memberCount >= 0 &&
obj.memberCount <= MAX_COUNT &&
typeof obj.atRiskMemberCount === "number" &&
Number.isFinite(obj.atRiskMemberCount) &&
Number.isSafeInteger(obj.atRiskMemberCount) &&
obj.atRiskMemberCount >= 0 &&
obj.atRiskMemberCount <= MAX_COUNT &&
Array.isArray(obj.memberDetails) &&
obj.memberDetails.length <= MAX_ARRAY_LENGTH &&
obj.memberDetails.every(isMemberDetails) &&
Array.isArray(obj.atRiskMemberDetails) &&
obj.atRiskMemberDetails.length <= MAX_ARRAY_LENGTH &&
obj.atRiskMemberDetails.every(isMemberDetails) &&
Array.isArray(obj.cipherIds) &&
obj.cipherIds.length <= MAX_ARRAY_LENGTH &&
obj.cipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
)
);
}
/**
* Type guard to validate OrganizationReportSummary structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportSummary(obj: any): obj is OrganizationReportSummary {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"totalMemberCount",
"totalApplicationCount",
"totalAtRiskMemberCount",
"totalAtRiskApplicationCount",
"totalCriticalApplicationCount",
"totalCriticalMemberCount",
"totalCriticalAtRiskMemberCount",
"totalCriticalAtRiskApplicationCount",
"newApplications",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.totalMemberCount === "number" &&
Number.isFinite(obj.totalMemberCount) &&
Number.isSafeInteger(obj.totalMemberCount) &&
obj.totalMemberCount >= 0 &&
obj.totalMemberCount <= MAX_COUNT &&
typeof obj.totalApplicationCount === "number" &&
Number.isFinite(obj.totalApplicationCount) &&
Number.isSafeInteger(obj.totalApplicationCount) &&
obj.totalApplicationCount >= 0 &&
obj.totalApplicationCount <= MAX_COUNT &&
typeof obj.totalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalAtRiskMemberCount) &&
obj.totalAtRiskMemberCount >= 0 &&
obj.totalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalAtRiskApplicationCount) &&
obj.totalAtRiskApplicationCount >= 0 &&
obj.totalAtRiskApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalApplicationCount) &&
obj.totalCriticalApplicationCount >= 0 &&
obj.totalCriticalApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalMemberCount === "number" &&
Number.isFinite(obj.totalCriticalMemberCount) &&
Number.isSafeInteger(obj.totalCriticalMemberCount) &&
obj.totalCriticalMemberCount >= 0 &&
obj.totalCriticalMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskMemberCount) &&
obj.totalCriticalAtRiskMemberCount >= 0 &&
obj.totalCriticalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskApplicationCount) &&
obj.totalCriticalAtRiskApplicationCount >= 0 &&
obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT &&
Array.isArray(obj.newApplications) &&
obj.newApplications.length <= MAX_ARRAY_LENGTH &&
obj.newApplications.every(
(app: any) => typeof app === "string" && app.length > 0 && app.length <= MAX_STRING_LENGTH,
)
);
}
/**
* Type guard to validate OrganizationReportApplication structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportApplication(obj: any): obj is OrganizationReportApplication {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["applicationName", "isCritical", "reviewedDate"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.isCritical === "boolean" &&
(obj.reviewedDate === null ||
obj.reviewedDate instanceof Date ||
typeof obj.reviewedDate === "string")
);
}
/**
* Validates and returns an array of ApplicationHealthReportDetail
* @throws Error if validation fails
*/
export function validateApplicationHealthReportDetailArray(
data: any,
): ApplicationHealthReportDetail[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid report data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isApplicationHealthReportDetail(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
);
}
return data as ApplicationHealthReportDetail[];
}
/**
* Validates and returns OrganizationReportSummary
* @throws Error if validation fails
*/
export function validateOrganizationReportSummary(data: any): OrganizationReportSummary {
if (!isOrganizationReportSummary(data)) {
const missingFields: string[] = [];
if (typeof data?.totalMemberCount !== "number") {
missingFields.push("totalMemberCount (number)");
}
if (typeof data?.totalApplicationCount !== "number") {
missingFields.push("totalApplicationCount (number)");
}
if (typeof data?.totalAtRiskMemberCount !== "number") {
missingFields.push("totalAtRiskMemberCount (number)");
}
if (typeof data?.totalAtRiskApplicationCount !== "number") {
missingFields.push("totalAtRiskApplicationCount (number)");
}
if (typeof data?.totalCriticalApplicationCount !== "number") {
missingFields.push("totalCriticalApplicationCount (number)");
}
if (typeof data?.totalCriticalMemberCount !== "number") {
missingFields.push("totalCriticalMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskMemberCount !== "number") {
missingFields.push("totalCriticalAtRiskMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskApplicationCount !== "number") {
missingFields.push("totalCriticalAtRiskApplicationCount (number)");
}
if (!Array.isArray(data?.newApplications)) {
missingFields.push("newApplications (string[])");
}
throw new Error(
`Invalid OrganizationReportSummary: ${missingFields.length > 0 ? `missing or invalid fields: ${missingFields.join(", ")}` : "structure validation failed"}`,
);
}
return data as OrganizationReportSummary;
}
/**
* Validates and returns an array of OrganizationReportApplication
* @throws Error if validation fails
*/
export function validateOrganizationReportApplicationArray(
data: any,
): OrganizationReportApplication[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isOrganizationReportApplication(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
);
}
// Convert string dates to Date objects for reviewedDate
return data.map((item) => ({
...item,
reviewedDate: item.reviewedDate
? item.reviewedDate instanceof Date
? item.reviewedDate
: (() => {
const date = new Date(item.reviewedDate);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date string: ${item.reviewedDate}`);
}
return date;
})()
: null,
})) as OrganizationReportApplication[];
}