diff --git a/README.md b/README.md index d227c57..2076d4f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,33 @@ return [ ]; ``` +### Twig text spam protection + +The Twig filter `spamsecure` replaces all dot and @-signs. + +```twig +{# Replace plain text #} +{{ text|spamsecure }} + +{# Replace rich text mails #} +{{ htmlText|spamsecure(true) }} +``` + + +### Configure the Bundle + +Create a configuration file called `nucleos_twig.yaml`: + +```yaml +# config/packages/nucleos_twig.yaml + +nucleos_twig: + secure: + mail: + at_text: [ ' [AT] ', ' (AT) ', ' [ÄT] ' ] + dot_text: [ ' [DOT] ', ' (DOT) ', ' [.] ' ] +``` + ## License This library is under the [MIT license](LICENSE.md). diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 9ed9ed5..e0920c6 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,8 +1,14 @@ - + + + + + + + - $prefixes[$exp - 1] + diff --git a/src/Bridge/Symfony/DependencyInjection/Configuration.php b/src/Bridge/Symfony/DependencyInjection/Configuration.php new file mode 100644 index 0000000..50b2e90 --- /dev/null +++ b/src/Bridge/Symfony/DependencyInjection/Configuration.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nucleos\Twig\Bridge\Symfony\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; + +final class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('nucleos_twig'); + + $rootNode = $treeBuilder->getRootNode(); + $rootNode->append($this->getSecureNode()); + + return $treeBuilder; + } + + private function getSecureNode(): NodeDefinition + { + $node = (new TreeBuilder('secure'))->getRootNode(); + + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('mail') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('dot_text') + ->useAttributeAsKey('id') + ->requiresAtLeastOneElement() + ->defaultValue([' [DOT] ', ' (DOT) ', ' [.] ']) + ->prototype('scalar')->end() + ->end() + ->arrayNode('at_text') + ->useAttributeAsKey('id') + ->requiresAtLeastOneElement() + ->defaultValue([' [AT] ', ' (AT) ', ' [ÄT] ']) + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $node; + } +} diff --git a/src/Bridge/Symfony/DependencyInjection/NucleosTwigExtension.php b/src/Bridge/Symfony/DependencyInjection/NucleosTwigExtension.php index 8eb2c52..d6e03a4 100644 --- a/src/Bridge/Symfony/DependencyInjection/NucleosTwigExtension.php +++ b/src/Bridge/Symfony/DependencyInjection/NucleosTwigExtension.php @@ -11,6 +11,7 @@ namespace Nucleos\Twig\Bridge\Symfony\DependencyInjection; +use Nucleos\Twig\Runtime\StringRuntime; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader; @@ -20,7 +21,23 @@ final class NucleosTwigExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + $loader = new Loader\PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.php'); + + $this->configureSecure($config['secure'], $container); + } + + /** + * @param array> $config + */ + private function configureSecure(array $config, ContainerBuilder $container): void + { + $container->getDefinition(StringRuntime::class) + ->replaceArgument(0, $config['mail']['at_text']) + ->replaceArgument(1, $config['mail']['dot_text']) + ; } } diff --git a/src/Bridge/Symfony/Resources/config/services.php b/src/Bridge/Symfony/Resources/config/services.php index 5d4fb92..03bd5c8 100644 --- a/src/Bridge/Symfony/Resources/config/services.php +++ b/src/Bridge/Symfony/Resources/config/services.php @@ -34,6 +34,10 @@ ->set(StringRuntime::class) ->tag('twig.runtime') + ->args([ + [], + [], + ]) ->set(RouterRuntime::class) ->tag('twig.runtime') diff --git a/src/Extension/StringExtension.php b/src/Extension/StringExtension.php index bb6bab1..c00e240 100644 --- a/src/Extension/StringExtension.php +++ b/src/Extension/StringExtension.php @@ -22,6 +22,9 @@ public function getFilters(): array return [ new TwigFilter('format_bytes', [StringRuntime::class, 'formatBytes']), new TwigFilter('obfuscate', [StringRuntime::class, 'obfuscate']), + new TwigFilter('spamsecure', [StringRuntime::class, 'spamsecure'], [ + 'is_safe' => ['html'], + ]), ]; } } diff --git a/src/Runtime/StringRuntime.php b/src/Runtime/StringRuntime.php index 849fece..be1e7ab 100644 --- a/src/Runtime/StringRuntime.php +++ b/src/Runtime/StringRuntime.php @@ -16,6 +16,29 @@ final class StringRuntime implements RuntimeExtensionInterface { + private const MAIL_HTML_PATTERN = '/\]+)href\=\"mailto\:([^">]+)\"(?:[^>]*)\>(.*?)\<\/a\>/ism'; + private const MAIL_TEXT_PATTERN = '/(([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})(\((.+?)\))?)/i'; + + /** + * @var string[] + */ + private readonly array $mailAtText; + + /** + * @var string[] + */ + private readonly array $mailDotText; + + /** + * @param string[] $mailAtText + * @param string[] $mailDotText + */ + public function __construct(array $mailAtText, array $mailDotText) + { + $this->mailAtText = $mailAtText; + $this->mailDotText = $mailDotText; + } + public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = 0, ?string $locale = null): string { if (null === $locale) { @@ -55,6 +78,98 @@ public function obfuscate(string $string, array $options = []): string return StringUtils::obfuscate($string, (int) $options['start'], (int) $options['end'], (string) $options['replacement']); } + /** + * Replaces email addresses with an alternative text representation. + * + * @param string $text input string + * @param bool $isHtml Secure html or text + * + * @return string with replaced links + */ + public function spamsecure(string $text, bool $isHtml = true): string + { + if ($isHtml) { + return preg_replace_callback(self::MAIL_HTML_PATTERN, [$this, 'encryptMailHtml'], $text) ?? ''; + } + + return preg_replace_callback(self::MAIL_TEXT_PATTERN, [$this, 'encryptMailText'], $text) ?? ''; + } + + /** + * @param string[] $matches + */ + private function encryptMailHtml(array $matches): string + { + [$original, $email, $text] = $matches; + + if ($text === $email) { + return sprintf( + '%s%s%s', + $this->createSecuredName($email), + $this->hashedArrayValue($this->mailAtText, $original), + $this->createSecuredName($email, true) + ); + } + + return sprintf( + '%s%s%s (%s)', + $this->createSecuredName($email), + $this->hashedArrayValue($this->mailAtText, $original), + $this->createSecuredName($email, true), + $text + ); + } + + /** + * @param string[] $matches + */ + private function encryptMailText(array $matches): string + { + [$original, $email] = $matches; + + return $this->createSecuredName($email). + $this->hashedArrayValue($this->mailAtText, $original). + $this->createSecuredName($email, true); + } + + private function createSecuredName(string $name, bool $isDomain = false): string + { + $index = strpos($name, '@'); + + \assert(false !== $index); + + if ($isDomain) { + $name = substr($name, $index + 1); + } else { + $name = substr($name, 0, $index); + } + + return str_replace('.', $this->hashedArrayValue($this->mailDotText, $name), $name); + } + + /** + * @param string[] $list + */ + private function hashedArrayValue(array $list, string $name): string + { + if ([] === $list) { + return ''; + } + + $count = \count($list); + + $index = $this->numericHash($name) % $count; + + return $list[$index]; + } + + private function numericHash(string $name): int + { + $hash = hash('sha256', $name); + + return \intval(substr($hash, 0, 6), 16); + } + private function getPrefix(bool $si, int $exp): string { $prefixes = ($si ? 'kMGTPE' : 'KMGTPE'); diff --git a/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..eb28b8e --- /dev/null +++ b/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nucleos\Twig\Tests\Bridge\Symfony\DependencyInjection; + +use Nucleos\Twig\Bridge\Symfony\DependencyInjection\Configuration; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Processor; + +final class ConfigurationTest extends TestCase +{ + public function testDefaultOptions(): void + { + $processor = new Processor(); + + $config = $processor->processConfiguration(new Configuration(), [[ + ]]); + + $expected = [ + 'secure' => [ + 'mail' => [ + 'dot_text' => [' [DOT] ', ' (DOT) ', ' [.] '], + 'at_text' => [' [AT] ', ' (AT) ', ' [ÄT] '], + ], + ], + ]; + + self::assertSame($expected, $config); + } + + public function testOptions(): void + { + $processor = new Processor(); + + $config = $processor->processConfiguration(new Configuration(), [[ + 'secure' => [ + 'mail' => [ + 'dot_text' => ['[DOT]'], + 'at_text' => ['[AT'], + ], + ], + ]]); + + $expected = [ + 'secure' => [ + 'mail' => [ + 'dot_text' => ['[DOT]'], + 'at_text' => ['[AT'], + ], + ], + ]; + + self::assertSame($expected, $config); + } +} diff --git a/tests/Bridge/Symfony/DependencyInjection/NucleosTwigExtensionTest.php b/tests/Bridge/Symfony/DependencyInjection/NucleosTwigExtensionTest.php index 2bc8473..3bdd748 100644 --- a/tests/Bridge/Symfony/DependencyInjection/NucleosTwigExtensionTest.php +++ b/tests/Bridge/Symfony/DependencyInjection/NucleosTwigExtensionTest.php @@ -16,6 +16,7 @@ use Nucleos\Twig\Extension\RouterExtension; use Nucleos\Twig\Extension\StringExtension; use Nucleos\Twig\Extension\UrlAutoConverterExtension; +use Nucleos\Twig\Runtime\StringRuntime; final class NucleosTwigExtensionTest extends AbstractExtensionTestCase { @@ -26,6 +27,13 @@ public function testLoadDefault(): void $this->assertContainerBuilderHasService(UrlAutoConverterExtension::class); $this->assertContainerBuilderHasService(StringExtension::class); $this->assertContainerBuilderHasService(RouterExtension::class); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument(StringRuntime::class, 0, [ + ' [AT] ', ' (AT) ', ' [ÄT] ', + ]); + $this->assertContainerBuilderHasServiceDefinitionWithArgument(StringRuntime::class, 1, [ + ' [DOT] ', ' (DOT) ', ' [.] ', + ]); } protected function getContainerExtensions(): array diff --git a/tests/Extension/StringExtensionTest.php b/tests/Extension/StringExtensionTest.php index e420af4..d7251d0 100644 --- a/tests/Extension/StringExtensionTest.php +++ b/tests/Extension/StringExtensionTest.php @@ -32,13 +32,11 @@ public function testGetFilters(): void self::assertCallable($filter->getCallable()); } - self::assertSame( - [ - 'format_bytes', - 'obfuscate', - ], - array_map(static fn (TwigFilter $filter): string => $filter->getName(), $filters) - ); + self::assertSame([ + 'format_bytes', + 'obfuscate', + 'spamsecure', + ], array_map(static fn (TwigFilter $filter): string => $filter->getName(), $filters)); } private static function assertCallable(mixed $callable): void diff --git a/tests/Runtime/StringRuntimeTest.php b/tests/Runtime/StringRuntimeTest.php index 3855fef..ebe92e9 100644 --- a/tests/Runtime/StringRuntimeTest.php +++ b/tests/Runtime/StringRuntimeTest.php @@ -17,9 +17,13 @@ final class StringRuntimeTest extends TestCase { + private StringRuntime $runtime; + protected function setUp(): void { Locale::setDefault('de-DE'); + + $this->runtime = new StringRuntime([' [AT] ', ' [ÄT] ', ' (AT) ', ' |AT| '], [' [DOT] ', ' PUNKT ', '[.]']); } /** @@ -29,11 +33,9 @@ protected function setUp(): void */ public function testFormatBytesBase10(string $expected, $bits): void { - $extension = new StringRuntime(); - self::assertSame( $expected, - $extension->formatBytes($bits, true, 1) + $this->runtime->formatBytes($bits, true, 1) ); } @@ -44,11 +46,9 @@ public function testFormatBytesBase10(string $expected, $bits): void */ public function testFormatBytesBase2(string $expected, $bits): void { - $extension = new StringRuntime(); - self::assertSame( $expected, - $extension->formatBytes($bits, false, 1) + $this->runtime->formatBytes($bits, false, 1) ); } @@ -88,11 +88,71 @@ public static function provideFormatBytesBase2Cases(): iterable ]; } - public function testObfuscate(): void + /** + * @dataProvider provideSpamSecureCases + */ + public function testSpamSecure(string $input, string $output): void + { + self::assertSame($output, $this->runtime->spamsecure($input)); + } + + /** + * @dataProvider provideSpamSecureTextCases + */ + public function testSpamSecureText(string $input, string $output): void + { + self::assertSame($output, $this->runtime->spamsecure($input, false)); + } + + /** + * @return string[][] + */ + public static function provideSpamSecureCases(): iterable + { + return [ + [ + 'Lorem Ipsum Sit Amet', + 'Lorem Ipsum Sit Amet', + ], + [ + 'Lorem Ipsum John Smith Sit Amet', + 'Lorem Ipsum john [AT] smith[.]cool (John Smith) Sit Amet', + ], + [ + 'Lorem Ipsum foo.sub@bar.baz.tld Sit Amet', + 'Lorem Ipsum foo [DOT] sub (AT) bar PUNKT baz PUNKT tld Sit Amet', + ], + [ + 'Lorem Ipsum foo [DOT] sub (AT) bar PUNKT baz PUNKT tld Sit Amet', + 'Lorem Ipsum foo [DOT] sub (AT) bar PUNKT baz PUNKT tld Sit Amet', + ], + ]; + } + + /** + * @return string[][] + */ + public static function provideSpamSecureTextCases(): iterable { - $extension = new StringRuntime(); + return [ + [ + 'Lorem Ipsum foo.sub@bar.baz.tld Sit Amet', + 'Lorem Ipsum foo [DOT] sub [AT] bar PUNKT baz PUNKT tld Sit Amet', + ], + [ + 'Lorem Ipsum foo [DOT] sub [AT] bar PUNKT baz PUNKT tld Sit Amet', + 'Lorem Ipsum foo [DOT] sub [AT] bar PUNKT baz PUNKT tld Sit Amet', + ], + [ + 'Lorem Ipsum foo[DOT]sub[AT]bar PUNKT baz PUNKT tld Sit Amet', + 'Lorem Ipsum foo[DOT]sub[AT]bar PUNKT baz PUNKT tld Sit Amet', + ], + ]; + } - self::assertSame('T***', $extension->obfuscate( + public function testObfuscate(): void + { + self::assertSame('T***', $this->runtime->obfuscate( 'Test', [ 'start' => 1,