diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a27cc34f..215186a42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,7 +26,8 @@
1. [](#new)
* Added `Deprecated` tab to DebugBar to catch future incompatibilities with later Grav versions
* Added deprecation notices for features which will be removed in Grav 2.0
- * Added `Utils::detectXssFromArray()` and `Utils::detectXss()` methods
+ * Added new `bin/grav security` command to scan for security issues (XSS currently)
+ * Added new `Security` class for Grav security functionality
* Added `onHttpPostFilter` event to allow plugins to globally clean up XSS in the forms and tasks
1. [](#bugfix)
* Allow `$page->slug()` to be called before `$page->init()` without breaking the page
diff --git a/bin/grav b/bin/grav
index 12269a781..cc660b1e4 100755
--- a/bin/grav
+++ b/bin/grav
@@ -43,5 +43,6 @@ $app->addCommands(array(
new \Grav\Console\Cli\BackupCommand(),
new \Grav\Console\Cli\NewProjectCommand(),
new \Grav\Console\Cli\SchedulerCommand(),
+ new \Grav\Console\Cli\SecurityCommand(),
));
$app->run();
diff --git a/system/blueprints/config/security.yaml b/system/blueprints/config/security.yaml
new file mode 100644
index 000000000..c99e2effd
--- /dev/null
+++ b/system/blueprints/config/security.yaml
@@ -0,0 +1,50 @@
+title: PLUGIN_ADMIN.SECURITY
+
+form:
+ validation: loose
+ fields:
+
+ security_section:
+ type: section
+ title: PLUGIN_ADMIN.XSS_SECURITY
+ underline: true
+
+ xss_whitelist:
+ type: selectize
+ size: large
+ label: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS
+ help: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS_HELP
+ placeholder: 'admin.super'
+ classes: fancy
+ validate:
+ type: commalist
+
+
+ xss_rules:
+ type: list
+ style: vertical
+ label: PLUGIN_ADMIN.XSS_RULES
+ help: PLUGIN_ADMIN.XSS_RULES_HELP
+ classes: compact
+ fields:
+ .label:
+ type: text
+ label: PLUGIN_ADMIN.XSS_RULE_LABEL
+ validate:
+ required: true
+ .regex:
+ type: text
+ label: PLUGIN_ADMIN.XSS_RULE_REGEX
+ validate:
+ required: true
+ .enabled:
+ type: toggle
+ label: PLUGIN_ADMIN.ENABLED
+ highlight: 1
+ options:
+ 1: PLUGIN_ADMIN.YES
+ 0: PLUGIN_ADMIN.NO
+ default: true
+ validate:
+ type: bool
+
diff --git a/system/config/security.yaml b/system/config/security.yaml
new file mode 100644
index 000000000..69b44dd80
--- /dev/null
+++ b/system/config/security.yaml
@@ -0,0 +1,22 @@
+xss_whitelist: [admin.super] # Whitelist of user access that should 'skip' XSS checking
+xss_rules: # Array of XSS tests to run through
+ -
+ label: On-Events
+ enabled: true
+ regex: '#(<[^>]+[\x00-\x20\"''\/])(on|xmlns)[^>]*>?#iUu'
+ -
+ label: JavaScript
+ enabled: true
+ regex: '!((java|live|vb)script|mocha|feed|data):(\w)*!iUu'
+ -
+ label: Moz-Binding
+ enabled: true
+ regex: '#-moz-binding[\x00-\x20]*:#u'
+ -
+ label: 'Style Attributes'
+ enabled: false
+ regex: '#(<[^>]+[\x00-\x20\"''\/])style=[^>]*>?#iUu'
+ -
+ label: 'Dangerous Tags'
+ enabled: true
+ regex: '#*(applet|meta|xml|blink|link|style|script|embed|object|iframe|frame|frameset|ilayer|layer|bgsound|title|base)[^>]*>?#ui'
diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php
new file mode 100644
index 000000000..9b345cb18
--- /dev/null
+++ b/system/src/Grav/Common/Security.php
@@ -0,0 +1,138 @@
+routes();
+
+ // Remove duplicate for homepage
+ unset($routes['/']);
+
+ $list = [];
+
+// // This needs Symfony 4.1 to work
+// $status && $status([
+// 'type' => 'count',
+// 'steps' => count($routes),
+// ]);
+
+ foreach ($routes as $route => $path) {
+
+ $status && $status([
+ 'type' => 'progress',
+ ]);
+
+ try {
+ $page = $pages->get($path);
+
+ // call the content to load/cache it
+ $header = (array) $page->header();
+ $content = $page->content();
+
+ $data = ['header' => $header, 'content' => $content];
+ $results = Security::detectXssFromArray($data);
+
+ if (!empty($results)) {
+ $list[$route] = $results;
+ }
+
+ } catch (\Exception $e) {
+ continue;
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param array $array Array such as $_POST or $_GET
+ * @param string $prefix Prefix for returned values.
+ * @return array Returns flatten list of potentially dangerous input values, such as 'data.content'.
+ */
+ public static function detectXssFromArray(array $array, $prefix = '')
+ {
+ $list = [];
+
+ foreach ($array as $key => $value) {
+ if (\is_array($value)) {
+ $list[] = static::detectXssFromArray($value, $prefix . $key . '.');
+ }
+ if ($result = static::detectXss($value)) {
+ $list[] = [$prefix . $key => $result];
+ }
+ }
+
+ if (!empty($list)) {
+ return array_merge(...$list);
+ }
+
+ return $list;
+ }
+
+ /**
+ * Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to
+ * return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into
+ * their content.
+ *
+ * @param string $string The string to run XSS detection logic on
+ * @return boolean|string Type of XSS vector if the given `$string` may contain XSS, false otherwise.
+ *
+ * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138
+ */
+ public static function detectXss($string)
+ {
+ // Skip any null or non string values
+ if (null === $string || !\is_string($string) || empty($string)) {
+ return false;
+ }
+
+ // Keep a copy of the original string before cleaning up
+ $orig = $string;
+
+ // URL decode
+ $string = urldecode($string);
+
+ // Convert Hexadecimals
+ $string = (string)preg_replace_callback('!(|\\\)[xX]([0-9a-fA-F]+);?!u', function($m) {
+ return \chr(hexdec($m[2]));
+ }, $string);
+
+ // Clean up entities
+ $string = preg_replace('!(+[0-9]+)!u','$1;', $string);
+
+ // Decode entities
+ $string = html_entity_decode($string, ENT_NOQUOTES, 'UTF-8');
+
+ // Strip whitespace characters
+ $string = preg_replace('!\s!u','', $string);
+
+ // Get XSS rules from security configuration
+ $xss_rules = Grav::instance()['config']->get('security.xss_rules');
+
+ // Iterate over rules and return label if fail
+ foreach ((array) $xss_rules as $rule) {
+ if ($rule['enabled'] === true) {
+ $label = $rule['label'];
+ $regex = $rule['regex'];
+
+ if ($label && $regex) {
+ if (preg_match($regex, $string) || preg_match($regex, $orig)) {
+ return $label;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php
index 09ebf45eb..772702276 100644
--- a/system/src/Grav/Common/Utils.php
+++ b/system/src/Grav/Common/Utils.php
@@ -253,90 +253,6 @@ abstract class Utils
return $date_formats;
}
- /**
- * @param array $array Array such as $_POST or $_GET
- * @param string $prefix Prefix for returned values.
- * @return array Returns flatten list of potentially dangerous input values, such as 'data.content'.
- */
- public static function detectXssFromArray(array $array, $prefix = '')
- {
- $list = [];
-
- foreach ($array as $key => $value) {
- if (\is_array($value)) {
- $list[] = static::detectXssFromArray($value, $prefix . $key . '.');
- }
- if (static::detectXss($value)) {
- $list[] = [$prefix . $key];
- }
- }
-
- return array_merge(...$list);
- }
-
- /**
- * Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to
- * return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into
- * their content.
- *
- * @param string $string The string to run XSS detection logic on
- * @return boolean True if the given `$string` may contain XSS, false otherwise.
- *
- * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138
- */
- public static function detectXss($string)
- {
- // Skip any null or non string values
- if (null === $string || !\is_string($string) || empty($string)) {
- return false;
- }
-
- // Keep a copy of the original string before cleaning up
- $orig = $string;
-
- // URL decode
- $string = urldecode($string);
-
- // Convert Hexadecimals
- $string = (string)preg_replace_callback('!(|\\\)[xX]([0-9a-fA-F]+);?!u', function($m) {
- return \chr(hexdec($m[2]));
- }, $string);
-
- // Clean up entities
- $string = preg_replace('!(+[0-9]+)!u','$1;', $string);
-
- // Decode entities
- $string = html_entity_decode($string, ENT_NOQUOTES, 'UTF-8');
-
- // Strip whitespace characters
- $string = preg_replace('!\s!u','', $string);
-
- // Set the patterns we'll test against
- $patterns = [
- // Match any attribute starting with "on" or xmlns
- '#(<[^>]+[\x00-\x20\"\'\/])(on|xmlns)[^>]*>?#iUu',
-
- // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols
- '!((java|live|vb)script|mocha|feed|data):(\w)*!iUu',
-
- // Match -moz-bindings
- '#-moz-binding[\x00-\x20]*:#u',
-
- // Match style attributes
- '#(<[^>]+[\x00-\x20\"\'\/])style=[^>]*>?#iUu',
-
- // Match potentially dangerous tags
- '#*(applet|meta|xml|blink|link|style|script|embed|object|iframe|frame|frameset|ilayer|layer|bgsound|title|base)[^>]*>?#ui'
- ];
-
- foreach ($patterns as $pattern) {
- // Test both the original string and clean string
- if (preg_match($pattern, $string) || preg_match($pattern, $orig)) {
- return true;
- }
- }
- return false;
- }
/**
* Truncate text by number of characters but can cut off words.
*
diff --git a/system/src/Grav/Console/Cli/SecurityCommand.php b/system/src/Grav/Console/Cli/SecurityCommand.php
new file mode 100644
index 000000000..3361d44ce
--- /dev/null
+++ b/system/src/Grav/Console/Cli/SecurityCommand.php
@@ -0,0 +1,113 @@
+setName("security")
+ ->setDescription("Capable of running various Security checks")
+ ->setHelp('The security runs various security checks on your Grav site');
+
+ $this->source = getcwd();
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+
+
+ /** @var Grav $grav */
+ $grav = Grav::instance();
+
+ $grav['uri']->init();
+ $grav['config']->init();
+ $grav['debugger']->enabled(false);
+ $grav['streams'];
+ $grav['plugins']->init();
+ $grav['themes']->init();
+
+
+ $grav['twig']->init();
+ $grav['pages']->init();
+
+ $this->progress = new ProgressBar($this->output, (count($grav['pages']->routes()) - 1));
+ $this->progress->setFormat('Scanning %current% pages [%bar%] %percent:3s%% %elapsed:6s%');
+ $this->progress->setBarWidth(100);
+
+ $io = new SymfonyStyle($this->input, $this->output);
+ $io->title('Grav Security Check');
+
+ $output = Security::detectXssFromPages($grav['pages'], [$this, 'outputProgress']);
+
+ $io->newline(2);
+
+ if (!empty($output)) {
+
+ $counter = 1;
+ foreach ($output as $route => $results) {
+
+ $results_parts = array_map(function($value, $key) {
+ return $key.': \''.$value . '\'';
+ }, array_values($results), array_keys($results));
+
+ $io->writeln($counter++ .' - ' . $route . ' → ' . implode(', ', $results_parts) . '');
+ }
+
+ $io->error('Security Scan complete: ' . count($output) . ' potential XSS issues found...');
+
+ } else {
+ $io->success('Security Scan complete: No issues found...');
+ }
+
+ $io->newline(1);
+
+ }
+
+ /**
+ * @param $args
+ */
+ public function outputProgress($args)
+ {
+ switch ($args['type']) {
+ case 'count':
+ $steps = $args['steps'];
+ $freq = intval($steps > 100 ? round($steps / 100) : $steps);
+ $this->progress->setMaxSteps($steps);
+ $this->progress->setRedrawFrequency($freq);
+ break;
+ case 'progress':
+ if (isset($args['complete']) && $args['complete']) {
+ $this->progress->finish();
+ } else {
+ $this->progress->advance();
+ }
+ break;
+ }
+ }
+
+}
+