Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add spam secure filter #459

Merged
merged 2 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
return [
new TwigFilter('format_bytes', [StringRuntime::class, 'formatBytes']),
new TwigFilter('obfuscate', [StringRuntime::class, 'obfuscate']),
new TwigFilter('spamsecure', [StringRuntime::class, 'spamsecure'], [

Check warning on line 25 in src/Extension/StringExtension.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "ArrayItemRemoval": --- Original +++ New @@ @@ { 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']])]; + return [new TwigFilter('format_bytes', [StringRuntime::class, 'formatBytes']), new TwigFilter('obfuscate', [StringRuntime::class, 'obfuscate']), new TwigFilter('spamsecure', [StringRuntime::class, 'spamsecure'], [])]; } }
'is_safe' => ['html'],

Check warning on line 26 in src/Extension/StringExtension.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "ArrayItemRemoval": --- Original +++ New @@ @@ { 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']])]; + return [new TwigFilter('format_bytes', [StringRuntime::class, 'formatBytes']), new TwigFilter('obfuscate', [StringRuntime::class, 'obfuscate']), new TwigFilter('spamsecure', [StringRuntime::class, 'spamsecure'], ['is_safe' => []])]; } }
]),
];
}
}
115 changes: 115 additions & 0 deletions src/Runtime/StringRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,36 @@

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

Check warning on line 42 in src/Runtime/StringRuntime.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "TrueValue": --- Original +++ New @@ @@ $this->mailAtText = $mailAtText; $this->mailDotText = $mailDotText; } - public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = 0, ?string $locale = null): string + public function formatBytes(float $bytes, bool $si = false, int $fractionDigits = 0, ?string $locale = null): string { if (null === $locale) { $locale = Locale::getDefault();

Check warning on line 42 in src/Runtime/StringRuntime.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ $this->mailAtText = $mailAtText; $this->mailDotText = $mailDotText; } - public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = 0, ?string $locale = null): string + public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = -1, ?string $locale = null): string { if (null === $locale) { $locale = Locale::getDefault();

Check warning on line 42 in src/Runtime/StringRuntime.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ $this->mailAtText = $mailAtText; $this->mailDotText = $mailDotText; } - public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = 0, ?string $locale = null): string + public function formatBytes(float $bytes, bool $si = true, int $fractionDigits = 1, ?string $locale = null): string { if (null === $locale) { $locale = Locale::getDefault();
{
if (null === $locale) {
$locale = Locale::getDefault();
}

$unit = $si ? 1000 : 1024;

Check warning on line 48 in src/Runtime/StringRuntime.php

View workflow job for this annotation

GitHub Actions / run / Mutation Tests (8.3)

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ if (null === $locale) { $locale = Locale::getDefault(); } - $unit = $si ? 1000 : 1024; + $unit = $si ? 999 : 1024; if ($bytes < $unit) { $prefix = ''; $number = $bytes;

if ($bytes < $unit) {
$prefix = '';
Expand Down Expand Up @@ -55,6 +78,98 @@
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
Loading