diff --git a/services/nginx/app/classes/economic_export_sanitizer.php b/services/nginx/app/classes/economic_export_sanitizer.php new file mode 100644 index 00000000..b103ba0c --- /dev/null +++ b/services/nginx/app/classes/economic_export_sanitizer.php @@ -0,0 +1,111 @@ +', '|', "\0"]; + + /** + * Sanitize a value for use in a single-line text description. + * + * Transformations (in order): + * 1. Replaces "/" with "-" (the reported 400 trigger) + * 2. Strips control characters (\x00-\x1F) except \t and \n + * 3. Replaces tab with single space + * 4. Collapses newlines into spaces (text lines are single-line) + * 5. Collapses runs of spaces to a single space + * 6. Trims leading/trailing whitespace + * 7. Truncates to $maxLength with "..." suffix if needed + */ + public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string + { + if ($value === null) { + return ''; + } + $text = (string)$value; + if ($text === '') { + return ''; + } + // 1. Strip control characters except \t and \n + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + // 2. Replace tab with single space + $text = str_replace("\t", ' ', $text); + // 3. Collapse newlines to single space (text lines are single-line) + $text = preg_replace('/[\r\n]+/u', ' ', $text); + // 4. Replace forward slashes (the reported 400 trigger) + $text = str_replace('/', '-', $text); + // 5. Collapse runs of spaces + $text = preg_replace('/\s+/u', ' ', $text); + // 6. Trim + $text = trim($text); + // 7. Truncate with ellipsis if too long + if ($maxLength > 3 && mb_strlen($text) > $maxLength) { + $text = mb_substr($text, 0, $maxLength - 3) . '...'; + } elseif (mb_strlen($text) > $maxLength) { + $text = mb_substr($text, 0, $maxLength); + } + return $text; + } + + /** + * Sanitize a product number/identifier. + * + * Removes characters that are illegal in product numbers on most + * e-conomic setups (filesystem-unsafe + path separators). + */ + public static function sanitizeProductNumber(mixed $value): string + { + if ($value === null) { + return ''; + } + $text = (string)$value; + if ($text === '') { + return ''; + } + $text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text); + $text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text); + $text = trim($text); + if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) { + $text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH); + } + return $text; + } + + /** + * Sanitize a longer product description. + */ + public static function sanitizeProductDescription(mixed $value): string + { + return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH); + } + + /** + * Catch-all sanitizer for any user-input value going to e-conomic. + * Defaults to text-line rules. + */ + public static function sanitizeForEconApi(mixed $value): string + { + return self::sanitizeTextLine($value); + } +} diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 24fb6853..380090fa 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -185,45 +185,55 @@ class economic_invoice_draft $department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value()); // Parse the date of the transaction. $parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value())); + // Sanitize the department name (could contain "/" or other chars) + $department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100); // Add the text line to the draft invoice self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]"); // If there's a PO number, add it to the invoice if ($order->po->value() !== '') { - self::addTextLine('PO: ' . $order->po->value()); + self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value())); } // If there's a reference, add it to the invoice - if ($order->reference->value() !== '') { + $reference_value = $order->reference->value(); + if ($reference_value !== '') { self::addTextLine('Reference:'); + // Sanitize the whole reference (handles "/" → "-" per TRU-188) + $reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value); // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order->reference->value(), "\n")) { - foreach ( explode("\n", $order->reference->value()) as $line ) { + if (str_contains($reference_sanitized, "\n")) { + foreach ( explode("\n", $reference_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order->reference->value()); + self::addTextLine('# ' . $reference_sanitized); } } // Add the registration numbers (if any) $line_reg = ''; - if ($order->reg_1->value() !== '') - $line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value()); - if ($order->reg_2->value() !== '') - $line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value()); - if ($order->reg_3->value() !== '') - $line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value()); + if ($order->reg_1->value() !== '') { + $line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50)); + } + if ($order->reg_2->value() !== '') { + $line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50)); + } + if ($order->reg_3->value() !== '') { + $line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50)); + } // Add the line to the invoice (If there's any registration numbers) if ($line_reg !== '') self::addTextLine($line_reg); // If there's a note, add it to the invoice - if ($order->notes->value() !== '') { + $notes_value = $order->notes->value(); + if ($notes_value !== '') { self::addTextLine('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order->notes->value(), "\n")) { - foreach ( explode("\n", $order->notes->value()) as $line ) { + // Sanitize notes (could contain "/", newlines, special chars) + $notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value); + if (str_contains($notes_sanitized, "\n")) { + foreach ( explode("\n", $notes_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order->notes->value()); + self::addTextLine('# ' . $notes_sanitized); } } } @@ -341,26 +351,28 @@ class economic_invoice_draft // If there's a reference, add it to the line if ($order_item['reference'] !== '') { self::addTextLine('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order_item['reference'], "\n")) { - foreach ( explode("\n", $order_item['reference']) as $line ) { + // Sanitize the reference (handles "/" → "-" per TRU-188) + $item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']); + if (str_contains($item_reference_sanitized, "\n")) { + foreach ( explode("\n", $item_reference_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order_item['reference']); + self::addTextLine('# ' . $item_reference_sanitized); } } // If there's a note, add it to the line if (!empty($order_item['notes'])) { self::addTextLine('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order_item['notes'], "\n")) { - foreach ( explode("\n", $order_item['notes']) as $line ) { + // Sanitize notes (could contain "/", newlines, special chars) + $item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']); + if (str_contains($item_notes_sanitized, "\n")) { + foreach ( explode("\n", $item_notes_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order_item['notes']); + self::addTextLine('# ' . $item_notes_sanitized); } } @@ -470,6 +482,9 @@ class economic_invoice_draft */ public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void { + // Sanitize product identifier and description (defense in depth — also done at addLines()) + $productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber); + $description = \classes\economic_export_sanitizer::sanitizeProductDescription($description); // We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS. // Add a line to the invoice $line = [ diff --git a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php new file mode 100644 index 00000000..cd2bb52b --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php @@ -0,0 +1,224 @@ +assertSame('ABC-123-XYZ', economic_export_sanitizer::sanitizeTextLine('ABC/123/XYZ')); + $this->assertSame('Order 1 - 2 - 3', economic_export_sanitizer::sanitizeTextLine('Order 1 / 2 / 3')); + $this->assertSame('-leading and trailing-', economic_export_sanitizer::sanitizeTextLine('/leading and trailing/')); + } + + public function testControlCharactersAreStripped(): void + { + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x00lo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x01lo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x1Flo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x7F\x7Flo")); + } + + public function testTabIsReplacedWithSpace(): void + { + $this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine("a\tb\tc")); + } + + public function testNewlinesCollapsedToSpace(): void + { + $this->assertSame('line1 line2 line3', economic_export_sanitizer::sanitizeTextLine("line1\nline2\nline3")); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2")); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\n\n\nline2")); + } + + public function testMultipleSpacesCollapsed(): void + { + $this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine('a b c')); + } + + public function testTrimsLeadingAndTrailingWhitespace(): void + { + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine(' hello ')); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("\n\thello\n\t")); + } + + public function testTruncatesAtMaxLengthWithEllipsis(): void + { + $text = str_repeat('a', 300); + $result = economic_export_sanitizer::sanitizeTextLine($text, 250); + $this->assertSame(250, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testTruncatesAtMaxLengthWithoutEllipsisWhenTooShort(): void + { + // When maxLength is 3, no room for ellipsis + $text = str_repeat('a', 100); + $result = economic_export_sanitizer::sanitizeTextLine($text, 3); + $this->assertSame(3, mb_strlen($result)); + $this->assertSame('aaa', $result); + } + + public function testDoesNotTruncateWhenShorterThanMaxLength(): void + { + $this->assertSame('short text', economic_export_sanitizer::sanitizeTextLine('short text', 250)); + } + + public function testNullReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine(null)); + } + + public function testEmptyReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('')); + } + + public function testWhitespaceOnlyReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine(" \t\n ")); + } + + public function testWhitespaceOnlyWithSlashesReturnsEmptyString(): void + { + // After all transformations, "///" becomes "---" + // After trim of whitespace-only, " / " becomes "" (since / is replaced but space was there) + // Actually let's see: " / " -> " " stays; " - " -> "-"; then trim -> "-" + // So it doesn't become empty in this case. Let me re-test: + $result = economic_export_sanitizer::sanitizeTextLine(' / '); + $this->assertSame('-', $result); + } + + public function testHandlesMultibyteChars(): void + { + $this->assertSame('æøå', economic_export_sanitizer::sanitizeTextLine('æøå')); + $this->assertSame('中文', economic_export_sanitizer::sanitizeTextLine('中文')); + $this->assertSame('🚗 car', economic_export_sanitizer::sanitizeTextLine('🚗 car')); + } + + public function testTruncationRespectsMultibyteBoundaries(): void + { + $text = str_repeat('æ', 300); + $result = economic_export_sanitizer::sanitizeTextLine($text, 10); + $this->assertSame(10, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testHtmlTagsAreNotStripped(): void + { + // We don't strip HTML — that's a different concern (XSS). We just sanitize for e-conomic. + // The "/" in gets replaced with "-" (per the rules). + $this->assertSame('notags<-b>', economic_export_sanitizer::sanitizeTextLine('notags')); + } + + public function testSlashesInTheMiddleOfValueAreReplaced(): void + { + $this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeTextLine('foo/bar/baz')); + } + + public function testMultipleProblemCharsCombined(): void + { + $input = "AB/\nC\t\rD\x00E "; + $result = economic_export_sanitizer::sanitizeTextLine($input); + // After: strip control -> "AB/\nC\tDE ", tab->space -> "AB/\nC DE ", + // newline->space -> "AB/ C DE ", slash->dash -> "AB- C DE ", + // collapse spaces -> "AB- C DE ", trim -> "AB- C DE" + $this->assertSame('AB- C DE', $result); + } + + public function testIntegerIsConvertedToString(): void + { + $this->assertSame('42', economic_export_sanitizer::sanitizeTextLine(42)); + } + + public function testFloatIsConvertedToString(): void + { + $this->assertSame('3.14', economic_export_sanitizer::sanitizeTextLine(3.14)); + } + + // ======================================================================== + // sanitizeProductNumber + // ======================================================================== + + public function testProductNumberRemovesPathSeparators(): void + { + $this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC/DEF')); + $this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC\\DEF')); + } + + public function testProductNumberRemovesForbiddenChars(): void + { + $input = "PROD:01?*<>|\""; + $result = economic_export_sanitizer::sanitizeProductNumber($input); + $this->assertSame('PROD01', $result); + } + + public function testProductNumberTruncatesAt50Chars(): void + { + $text = str_repeat('a', 100); + $result = economic_export_sanitizer::sanitizeProductNumber($text); + $this->assertSame(50, mb_strlen($result)); + } + + public function testProductNumberTrimsWhitespace(): void + { + $this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber(' PROD01 ')); + } + + public function testProductNumberStripsControlChars(): void + { + $this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber("PROD\x0001")); + } + + public function testProductNumberNullReturnsEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeProductNumber(null)); + } + + public function testProductNumberAllForbiddenReturnsEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeProductNumber('///\\\\::')); + } + + public function testProductNumberKeepsDotsAndDashes(): void + { + $this->assertSame('PROD-01.0', economic_export_sanitizer::sanitizeProductNumber('PROD-01.0')); + } + + // ======================================================================== + // sanitizeProductDescription + // ======================================================================== + + public function testProductDescriptionTruncatesAt500(): void + { + $text = str_repeat('a', 1000); + $result = economic_export_sanitizer::sanitizeProductDescription($text); + $this->assertSame(500, mb_strlen($result)); + } + + public function testProductDescriptionReplacesSlashes(): void + { + $this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeProductDescription('foo/bar/baz')); + } + + // ======================================================================== + // sanitizeForEconApi + // ======================================================================== + + public function testSanitizeForEconApiIsAliasForTextLine(): void + { + $this->assertSame( + economic_export_sanitizer::sanitizeTextLine('foo/bar'), + economic_export_sanitizer::sanitizeForEconApi('foo/bar') + ); + } +}