@@ -17,12 +17,10 @@ export interface TransformOptions {
1717 deep ?: boolean ;
1818}
1919
20- function padBase64 ( value : string ) : string {
21- const remainder = value . length % 4 ;
22- if ( remainder === 0 ) return value ;
23- return value + "=" . repeat ( 4 - remainder ) ;
24- }
25-
20+ /**
21+ * Check if a string contains mostly printable text
22+ * Used to validate that decoded base64 produces readable content
23+ */
2624function isPrintableText ( value : string ) : boolean {
2725 if ( ! value ) return true ;
2826
@@ -43,14 +41,33 @@ function isPrintableText(value: string): boolean {
4341 return printableCount / value . length >= 0.85 ;
4442}
4543
46- function decodeBase64String ( value : string ) : string | null {
44+ /**
45+ * Strictly validate and decode a base64 string
46+ * Returns decoded string only if:
47+ * 1. Input is valid base64 format
48+ * 2. Decoded content is printable text
49+ * 3. Re-encoding produces the same result (round-trip validation)
50+ */
51+ function tryDecodeBase64 ( value : string ) : string | null {
52+ if ( ! value || typeof value !== "string" ) return null ;
53+
54+ const trimmed = value . trim ( ) ;
55+
56+ if ( trimmed . length < 4 ) return null ;
57+
58+ if ( ! BASE64_REGEX . test ( trimmed ) ) return null ;
59+
60+ const paddingNeeded = ( 4 - ( trimmed . length % 4 ) ) % 4 ;
61+ const padded = trimmed + "=" . repeat ( paddingNeeded ) ;
62+
4763 try {
48- const decoded = Buffer . from ( value , "base64" ) . toString ( "utf8" ) ;
64+ const decoded = Buffer . from ( padded , "base64" ) . toString ( "utf8" ) ;
65+
4966 if ( ! isPrintableText ( decoded ) ) {
5067 return null ;
5168 }
5269
53- const normalizedInput = value . replaceAll ( / = + $ / g, "" ) ;
70+ const normalizedInput = trimmed . replaceAll ( / = + $ / g, "" ) ;
5471 const reencoded = Buffer . from ( decoded , "utf8" )
5572 . toString ( "base64" )
5673 . replaceAll ( / = + $ / g, "" ) ;
@@ -61,46 +78,22 @@ function decodeBase64String(value: string): string | null {
6178 }
6279}
6380
64- function findBase64Payload ( rawValue : string ) : Base64Payload | null {
65- if ( ! rawValue ) return null ;
66-
67- const trimmed = rawValue . trim ( ) ;
68- if ( ! trimmed ) return null ;
69-
70- const primaryCandidate =
71- trimmed . length >= 8 &&
72- BASE64_FULL_REGEX . test ( trimmed ) &&
73- decodeBase64String ( trimmed ) ;
74-
75- if ( typeof primaryCandidate === "string" ) {
76- return { candidate : trimmed , decoded : primaryCandidate } ;
77- }
78-
79- const matches = trimmed . match ( BASE64_SEGMENT_REGEX ) ;
80- if ( ! matches ) return null ;
81-
82- for ( const match of matches ) {
83- if ( ! match ) continue ;
84- const padded = padBase64 ( match ) ;
85- const decoded = decodeBase64String ( padded ) ;
86- if ( decoded !== null ) {
87- return { candidate : padded , decoded } ;
88- }
89- }
90-
91- return null ;
92- }
93-
81+ /**
82+ * Decode multiple layers of base64 encoding
83+ * Handles cases where data was encoded multiple times
84+ */
9485function decodeBase64Layers ( value : string ) : string {
86+ if ( ! value || typeof value !== "string" ) return value ;
87+
9588 let current = value ;
9689 let depth = 0 ;
9790
9891 while ( depth < MAX_BASE64_DEPTH ) {
99- const payload = findBase64Payload ( current ) ;
100- if ( ! payload ) break ;
92+ const decoded = tryDecodeBase64 ( current ) ;
10193
102- const decoded = payload . decoded ;
103- if ( decoded === current ) break ;
94+ if ( decoded === null || decoded === current ) {
95+ break ;
96+ }
10497
10598 current = decoded ;
10699 depth += 1 ;
@@ -146,16 +139,9 @@ function matchesConfiguredField(
146139
147140export const TRANSFORM_METADATA_KEY = "data-transform" ;
148141
149- const HTML_TAG_REGEX = / < \/ ? [ a - z ] [ \S \s ] * > / i;
150- const BASE64_FULL_REGEX = / ^ [ \d + / A - Z a - z ] + = { 0 , 2 } $ / ;
151- const BASE64_SEGMENT_REGEX = / [ \d + / = A - Z a - z ] { 4 , } / g;
142+ const BASE64_REGEX = / ^ [ \d + / A - Z a - z ] + = * $ / ;
152143const MAX_BASE64_DEPTH = 5 ;
153144
154- interface Base64Payload {
155- candidate : string ;
156- decoded : string ;
157- }
158-
159145/**
160146 * Decorator to configure data transformation for endpoints
161147 */
@@ -326,7 +312,6 @@ export class DataTransformInterceptor implements NestInterceptor {
326312 fieldPath : string ,
327313 operation : "encode" | "decode" ,
328314 ) : boolean {
329- // Only transform explicitly configured fields
330315 if ( ! fields || fields . length === 0 ) {
331316 return false ;
332317 }
@@ -336,7 +321,6 @@ export class DataTransformInterceptor implements NestInterceptor {
336321 return false ;
337322 }
338323
339- // Skip encoding short numeric strings (like "45", "2027")
340324 if ( typeof value === "string" ) {
341325 const trimmedValue = value . trim ( ) ;
342326 if (
@@ -366,10 +350,9 @@ export class DataTransformInterceptor implements NestInterceptor {
366350 private decodeValue ( value : unknown ) : unknown {
367351 if ( typeof value !== "string" ) return value ;
368352
369- // Handle compressed data with 'comp:' prefix
370353 if ( value . startsWith ( "comp:" ) ) {
371354 try {
372- const base64Data = value . slice ( 5 ) ; // Remove 'comp:' prefix
355+ const base64Data = value . slice ( 5 ) ;
373356 const decoded = Buffer . from ( base64Data , "base64" ) . toString ( "utf8" ) ;
374357 const fullyDecoded = decodeBase64Layers ( decoded ) ;
375358 try {
0 commit comments