Skip to content

Commit

Permalink
Add spam secure filter
Browse files Browse the repository at this point in the history
  • Loading branch information
core23 committed Apr 30, 2024
1 parent ba80988 commit a594d0e
Show file tree
Hide file tree
Showing 11 changed files with 376 additions and 18 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 8 additions & 2 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.16.0@2897ba636551a8cb61601cc26f6ccfbba6c36591">
<files psalm-version="5.23.1@8471a896ccea3526b26d082f4461eeea467f10a4">
<file src="src/Bridge/Symfony/DependencyInjection/Configuration.php">
<UndefinedMethod>
<code><![CDATA[addDefaultsIfNotSet]]></code>
<code><![CDATA[append]]></code>
</UndefinedMethod>
</file>
<file src="src/Runtime/StringRuntime.php">
<InvalidArrayOffset>
<code>$prefixes[$exp - 1]</code>
<code><![CDATA[$prefixes[$exp - 1]]]></code>
</InvalidArrayOffset>
</file>
</files>
59 changes: 59 additions & 0 deletions src/Bridge/Symfony/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

/*
* (c) Christian Gripp <mail@core23.de>
*
* 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;
}
}
17 changes: 17 additions & 0 deletions src/Bridge/Symfony/DependencyInjection/NucleosTwigExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, array<string, mixed>> $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'])
;
}
}
4 changes: 4 additions & 0 deletions src/Bridge/Symfony/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@

->set(StringRuntime::class)
->tag('twig.runtime')
->args([
[],
[],
])

->set(RouterRuntime::class)
->tag('twig.runtime')
Expand Down
3 changes: 3 additions & 0 deletions src/Extension/StringExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]),
];
}
}
115 changes: 115 additions & 0 deletions src/Runtime/StringRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@

final class StringRuntime implements RuntimeExtensionInterface
{
private const MAIL_HTML_PATTERN = '/\<a(?:[^>]+)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) {
Expand Down Expand Up @@ -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');
Expand Down
61 changes: 61 additions & 0 deletions tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* (c) Christian Gripp <mail@core23.de>
*
* 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down
12 changes: 5 additions & 7 deletions tests/Extension/StringExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit a594d0e

Please sign in to comment.