[WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline
This commit is contained in:
parent
ce28a869cc
commit
571a1f2f04
@ -27,7 +27,6 @@
|
||||
{{ dump.data|raw }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" onload="var h = this.parentNode.innerHTML, rx=/<script>(.*?)<\/script>/g, s; while (s = rx.exec(h)) {eval(s[1]);};" />
|
||||
{% endset %}
|
||||
|
||||
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }}
|
||||
|
@ -11,6 +11,7 @@
|
||||
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Controller;
|
||||
|
||||
use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
|
||||
use Symfony\Bundle\WebProfilerBundle\Profiler\TemplateManager;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@ -33,6 +34,7 @@ class ProfilerController
|
||||
private $twig;
|
||||
private $templates;
|
||||
private $toolbarPosition;
|
||||
private $cspHandler;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
@ -43,13 +45,14 @@ class ProfilerController
|
||||
* @param array $templates The templates
|
||||
* @param string $toolbarPosition The toolbar position (top, bottom, normal, or null -- use the configuration)
|
||||
*/
|
||||
public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal')
|
||||
public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal', ContentSecurityPolicyHandler $cspHandler = null)
|
||||
{
|
||||
$this->generator = $generator;
|
||||
$this->profiler = $profiler;
|
||||
$this->twig = $twig;
|
||||
$this->templates = $templates;
|
||||
$this->toolbarPosition = $toolbarPosition;
|
||||
$this->cspHandler = $cspHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -88,6 +91,10 @@ class ProfilerController
|
||||
|
||||
$this->profiler->disable();
|
||||
|
||||
if (null !== $this->cspHandler) {
|
||||
$this->cspHandler->disableCsp();
|
||||
}
|
||||
|
||||
$panel = $request->query->get('panel', 'request');
|
||||
$page = $request->query->get('page', 'home');
|
||||
|
||||
@ -134,6 +141,10 @@ class ProfilerController
|
||||
|
||||
$this->profiler->disable();
|
||||
|
||||
if (null !== $this->cspHandler) {
|
||||
$this->cspHandler->disableCsp();
|
||||
}
|
||||
|
||||
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
|
||||
'about' => $about,
|
||||
'request' => $request,
|
||||
@ -185,7 +196,7 @@ class ProfilerController
|
||||
// the profiler is not enabled
|
||||
}
|
||||
|
||||
return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
|
||||
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
|
||||
'request' => $request,
|
||||
'position' => $position,
|
||||
'profile' => $profile,
|
||||
@ -193,7 +204,7 @@ class ProfilerController
|
||||
'profiler_url' => $url,
|
||||
'token' => $token,
|
||||
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
|
||||
)), 200, array('Content-Type' => 'text/html'));
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -213,6 +224,10 @@ class ProfilerController
|
||||
|
||||
$this->profiler->disable();
|
||||
|
||||
if (null !== $this->cspHandler) {
|
||||
$this->cspHandler->disableCsp();
|
||||
}
|
||||
|
||||
if (null === $session = $request->getSession()) {
|
||||
$ip =
|
||||
$method =
|
||||
@ -268,6 +283,10 @@ class ProfilerController
|
||||
|
||||
$this->profiler->disable();
|
||||
|
||||
if (null !== $this->cspHandler) {
|
||||
$this->cspHandler->disableCsp();
|
||||
}
|
||||
|
||||
$profile = $this->profiler->loadProfile($token);
|
||||
|
||||
$ip = $request->query->get('ip');
|
||||
@ -364,6 +383,10 @@ class ProfilerController
|
||||
|
||||
$this->profiler->disable();
|
||||
|
||||
if (null !== $this->cspHandler) {
|
||||
$this->cspHandler->disableCsp();
|
||||
}
|
||||
|
||||
ob_start();
|
||||
phpinfo();
|
||||
$phpinfo = ob_get_clean();
|
||||
@ -384,4 +407,18 @@ class ProfilerController
|
||||
|
||||
return $this->templateManager;
|
||||
}
|
||||
|
||||
private function renderWithCspNonces(Request $request, $template, $variables, $code = 200, $headers = array('Content-Type' => 'text/html'))
|
||||
{
|
||||
$response = new Response('', $code, $headers);
|
||||
|
||||
$nonces = $this->cspHandler ? $this->cspHandler->getNonces($request, $response) : array();
|
||||
|
||||
$variables['csp_script_nonce'] = isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null;
|
||||
$variables['csp_style_nonce'] = isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null;
|
||||
|
||||
$response->setContent($this->twig->render($template, $variables));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Csp;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Handles Content-Security-Policy HTTP header for the WebProfiler Bundle.
|
||||
*
|
||||
* @author Romain Neutron <imprec@gmail.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ContentSecurityPolicyHandler
|
||||
{
|
||||
private $nonceGenerator;
|
||||
private $cspDisabled = false;
|
||||
|
||||
public function __construct(NonceGenerator $nonceGenerator)
|
||||
{
|
||||
$this->nonceGenerator = $nonceGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers.
|
||||
*
|
||||
* Nonce can be provided by;
|
||||
* - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin
|
||||
* - The response - A call to getNonces() has already been done previously. Same nonce are returned
|
||||
* - They are otherwise randomly generated
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getNonces(Request $request, Response $response)
|
||||
{
|
||||
if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) {
|
||||
return array(
|
||||
'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'),
|
||||
'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) {
|
||||
return array(
|
||||
'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'),
|
||||
'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'),
|
||||
);
|
||||
}
|
||||
|
||||
$nonces = array(
|
||||
'csp_script_nonce' => $this->generateNonce(),
|
||||
'csp_style_nonce' => $this->generateNonce(),
|
||||
);
|
||||
|
||||
$response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']);
|
||||
$response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']);
|
||||
|
||||
return $nonces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables Content-Security-Policy.
|
||||
*
|
||||
* All related headers will be removed.
|
||||
*/
|
||||
public function disableCsp()
|
||||
{
|
||||
$this->cspDisabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup temporary headers and updates Content-Security-Policy headers.
|
||||
*
|
||||
* @return array Nonces used by the bundle in Content-Security-Policy header
|
||||
*/
|
||||
public function updateResponseHeaders(Request $request, Response $response)
|
||||
{
|
||||
if ($this->cspDisabled) {
|
||||
$this->removeCspHeaders($response);
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
$nonces = $this->getNonces($request, $response);
|
||||
$this->cleanHeaders($response);
|
||||
$this->updateCspHeaders($response, $nonces);
|
||||
|
||||
return $nonces;
|
||||
}
|
||||
|
||||
private function cleanHeaders(Response $response)
|
||||
{
|
||||
$response->headers->remove('X-SymfonyProfiler-Script-Nonce');
|
||||
$response->headers->remove('X-SymfonyProfiler-Style-Nonce');
|
||||
}
|
||||
|
||||
private function removeCspHeaders(Response $response)
|
||||
{
|
||||
$response->headers->remove('X-Content-Security-Policy');
|
||||
$response->headers->remove('Content-Security-Policy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Content-Security-Policy headers in a response.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function updateCspHeaders(Response $response, array $nonces = array())
|
||||
{
|
||||
$nonces = array_replace(array(
|
||||
'csp_script_nonce' => $this->generateNonce(),
|
||||
'csp_style_nonce' => $this->generateNonce(),
|
||||
), $nonces);
|
||||
|
||||
$ruleIsSet = false;
|
||||
|
||||
$headers = $this->getCspHeaders($response);
|
||||
|
||||
foreach ($headers as $header => $directives) {
|
||||
foreach (array('script-src' => 'csp_script_nonce', 'style-src' => 'csp_style_nonce') as $type => $tokenName) {
|
||||
if ($this->authorizesInline($directives, $type)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($headers[$header][$type])) {
|
||||
if (isset($headers[$header]['default-src'])) {
|
||||
$headers[$header][$type] = $headers[$header]['default-src'];
|
||||
} else {
|
||||
$headers[$header][$type] = array();
|
||||
}
|
||||
}
|
||||
$ruleIsSet = true;
|
||||
if (!in_array('\'unsafe-inline\'', $headers[$header][$type], true)) {
|
||||
$headers[$header][$type][] = '\'unsafe-inline\'';
|
||||
}
|
||||
$headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$ruleIsSet) {
|
||||
return $nonces;
|
||||
}
|
||||
|
||||
foreach ($headers as $header => $directives) {
|
||||
$response->headers->set($header, $this->generateCspHeader($directives));
|
||||
}
|
||||
|
||||
return $nonces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a valid Content-Security-Policy nonce.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateNonce()
|
||||
{
|
||||
return $this->nonceGenerator->generate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a directive set array into Content-Security-Policy header.
|
||||
*
|
||||
* @param array $directives The directive set
|
||||
*
|
||||
* @return string The Content-Security-Policy header
|
||||
*/
|
||||
private function generateCspHeader(array $directives)
|
||||
{
|
||||
return array_reduce(array_keys($directives), function ($res, $name) use ($directives) {
|
||||
return ($res !== '' ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name]));
|
||||
}, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Content-Security-Policy header value into a directive set array.
|
||||
*
|
||||
* @param string $header The header value
|
||||
*
|
||||
* @return array The directive set
|
||||
*/
|
||||
private function parseDirectives($header)
|
||||
{
|
||||
$directives = array();
|
||||
|
||||
foreach (explode(';', $header) as $directive) {
|
||||
$parts = explode(' ', trim($directive));
|
||||
if (count($parts) < 1) {
|
||||
continue;
|
||||
}
|
||||
$name = array_shift($parts);
|
||||
$directives[$name] = $parts;
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the 'unsafe-inline' is prevented for a directive within the directive set.
|
||||
*
|
||||
* @param array $directivesSet The directive set
|
||||
* @param string $type The name of the directive to check
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function authorizesInline(array $directivesSet, $type)
|
||||
{
|
||||
if (isset($directivesSet[$type])) {
|
||||
$directives = $directivesSet[$type];
|
||||
} elseif (isset($directivesSet['default-src'])) {
|
||||
$directives = $directivesSet['default-src'];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
|
||||
}
|
||||
|
||||
private function hasHashOrNonce(array $directives)
|
||||
{
|
||||
foreach ($directives as $directive) {
|
||||
if ('\'' !== substr($directive, -1)) {
|
||||
continue;
|
||||
}
|
||||
if ('\'nonce-' === substr($directive, 0, 7)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
|
||||
* a response.
|
||||
*
|
||||
* @return array An associative array of headers
|
||||
*/
|
||||
private function getCspHeaders(Response $response)
|
||||
{
|
||||
$headers = array();
|
||||
|
||||
if ($response->headers->has('Content-Security-Policy')) {
|
||||
$headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
if ($response->headers->has('X-Content-Security-Policy')) {
|
||||
$headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
27
src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php
Normal file
27
src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Csp;
|
||||
|
||||
/**
|
||||
* Generates Content-Security-Policy nonce.
|
||||
*
|
||||
* @author Romain Neutron <imprec@gmail.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class NonceGenerator
|
||||
{
|
||||
public function generate()
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
|
||||
namespace Symfony\Bundle\WebProfilerBundle\EventListener;
|
||||
|
||||
use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag;
|
||||
@ -40,8 +41,9 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
protected $mode;
|
||||
protected $position;
|
||||
protected $excludedAjaxPaths;
|
||||
private $cspHandler;
|
||||
|
||||
public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt')
|
||||
public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt', ContentSecurityPolicyHandler $cspHandler = null)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
@ -49,6 +51,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
$this->mode = (int) $mode;
|
||||
$this->position = $position;
|
||||
$this->excludedAjaxPaths = $excludedAjaxPaths;
|
||||
$this->cspHandler = $cspHandler;
|
||||
}
|
||||
|
||||
public function isEnabled()
|
||||
@ -76,6 +79,8 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$nonces = $this->cspHandler ? $this->cspHandler->updateResponseHeaders($request, $response) : array();
|
||||
|
||||
// do not capture redirects or modify XML HTTP Requests
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return;
|
||||
@ -102,7 +107,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->injectToolbar($response, $request);
|
||||
$this->injectToolbar($response, $request, $nonces);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -110,7 +115,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
*
|
||||
* @param Response $response A Response instance
|
||||
*/
|
||||
protected function injectToolbar(Response $response, Request $request)
|
||||
protected function injectToolbar(Response $response, Request $request, array $nonces)
|
||||
{
|
||||
$content = $response->getContent();
|
||||
$pos = strripos($content, '</body>');
|
||||
@ -123,6 +128,8 @@ class WebDebugToolbarListener implements EventSubscriberInterface
|
||||
'excluded_ajax_paths' => $this->excludedAjaxPaths,
|
||||
'token' => $response->headers->get('X-Debug-Token'),
|
||||
'request' => $request,
|
||||
'csp_script_nonce' => isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null,
|
||||
'csp_style_nonce' => isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null,
|
||||
)
|
||||
))."\n";
|
||||
$content = substr($content, 0, $pos).$toolbar.substr($content, $pos);
|
||||
|
@ -11,6 +11,7 @@
|
||||
<argument type="service" id="twig" />
|
||||
<argument>%data_collector.templates%</argument>
|
||||
<argument>%web_profiler.debug_toolbar.position%</argument>
|
||||
<argument type="service" id="web_profiler.csp.handler" />
|
||||
</service>
|
||||
|
||||
<service id="web_profiler.controller.router" class="Symfony\Bundle\WebProfilerBundle\Controller\RouterController">
|
||||
@ -25,6 +26,12 @@
|
||||
<argument>%kernel.debug%</argument>
|
||||
</service>
|
||||
|
||||
<service id="web_profiler.csp.handler" class="Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler" public="false">
|
||||
<argument type="service">
|
||||
<service class="Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator" />
|
||||
</argument>
|
||||
</service>
|
||||
|
||||
<service id="twig.extension.webprofiler" class="Symfony\Bundle\WebProfilerBundle\Twig\WebProfilerExtension" public="false">
|
||||
<tag name="twig.extension" />
|
||||
</service>
|
||||
|
@ -13,6 +13,7 @@
|
||||
<argument>%web_profiler.debug_toolbar.position%</argument>
|
||||
<argument type="service" id="router" on-invalid="ignore" />
|
||||
<argument /> <!-- paths that should be excluded from the AJAX requests shown in the toolbar -->
|
||||
<argument type="service" id="web_profiler.csp.handler" />
|
||||
</service>
|
||||
</services>
|
||||
</container>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script>/*<![CDATA[*/
|
||||
<script{% if csp_script_nonce is defined and csp_script_nonce %} nonce={{ csp_script_nonce }}{% endif %}>/*<![CDATA[*/
|
||||
{# Caution: the contents of this file are processed by Twig before loading
|
||||
them as JavaScript source code. Always use '/*' comments instead
|
||||
of '//' comments to avoid impossible-to-debug side-effects #}
|
||||
|
@ -94,7 +94,7 @@
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-shortcuts">
|
||||
<div class="shortcuts">
|
||||
<a href="#" class="visible-small" onclick="Sfjs.toggleClass(document.getElementById('sidebar'), 'expanded'); return false;">
|
||||
<a href="#" id="sidebarShortcutsMenu" class="visible-small">
|
||||
<span class="icon">{{ include('@WebProfiler/Icon/menu.svg') }}</span>
|
||||
</a>
|
||||
|
||||
@ -124,4 +124,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
Sfjs.addEventListener(document.getElementById('sidebarShortcutsMenu'), 'click', function (event) {
|
||||
event.preventDefault();
|
||||
Sfjs.toggleClass(document.getElementById('sidebar'), 'expanded');
|
||||
})
|
||||
}())
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -26,6 +26,15 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sf-toolbar-clearer {
|
||||
clear: both;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.sf-display-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sf-toolbarreset * {
|
||||
-webkit-box-sizing: content-box;
|
||||
-moz-box-sizing: content-box;
|
||||
|
@ -1,27 +1,14 @@
|
||||
<!-- START of Symfony Web Debug Toolbar -->
|
||||
{% if 'normal' != position %}
|
||||
<div id="sfMiniToolbar-{{ token }}" class="sf-minitoolbar" data-no-turbolink>
|
||||
<a href="javascript:void(0);" title="Show Symfony toolbar" tabindex="-1" accesskey="D" onclick="
|
||||
var elem = this.parentNode;
|
||||
if (elem.style.display == 'none') {
|
||||
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'none';
|
||||
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'none';
|
||||
elem.style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'block';
|
||||
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'block';
|
||||
elem.style.display = 'none'
|
||||
}
|
||||
|
||||
Sfjs.setPreference('toolbar/displayState', 'block');
|
||||
">
|
||||
<a href="#" title="Show Symfony toolbar" tabindex="-1" id="sfToolbarMiniToggler-{{ token }}" accesskey="D">
|
||||
{{ include('@WebProfiler/Icon/symfony.svg') }}
|
||||
</a>
|
||||
</div>
|
||||
<style>
|
||||
<style{% if csp_style_nonce %} nonce="{{ csp_style_nonce }}"{% endif %}>
|
||||
{{ include('@WebProfiler/Profiler/toolbar.css.twig', { 'position': position, 'floatable': true }) }}
|
||||
</style>
|
||||
<div id="sfToolbarClearer-{{ token }}" style="clear: both; height: 36px;"></div>
|
||||
<div id="sfToolbarClearer-{{ token }}" class="sf-toolbar-clearer"></div>
|
||||
{% endif %}
|
||||
|
||||
<div id="sfToolbarMainContent-{{ token }}" class="sf-toolbarreset clear-fix" data-no-turbolink>
|
||||
@ -31,19 +18,15 @@
|
||||
'profiler_url': profiler_url,
|
||||
'token': profile.token,
|
||||
'name': name,
|
||||
'profiler_markup_version': profiler_markup_version
|
||||
'profiler_markup_version': profiler_markup_version,
|
||||
'csp_script_nonce': csp_script_nonce,
|
||||
'csp_style_nonce': csp_style_nonce
|
||||
})
|
||||
}}
|
||||
{% endfor %}
|
||||
|
||||
{% if 'normal' != position %}
|
||||
<a class="hide-button" title="Close Toolbar" tabindex="-1" accesskey="D" onclick="
|
||||
var p = this.parentNode;
|
||||
p.style.display = 'none';
|
||||
(p.previousElementSibling || p.previousSibling).style.display = 'none';
|
||||
document.getElementById('sfMiniToolbar-{{ token }}').style.display = 'block';
|
||||
Sfjs.setPreference('toolbar/displayState', 'none');
|
||||
">
|
||||
<a class="hide-button" id="sfToolbarHideButton-{{ token }}" title="Close Toolbar" tabindex="-1" accesskey="D">
|
||||
{{ include('@WebProfiler/Icon/close.svg') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div id="sfwdt{{ token }}" class="sf-toolbar" style="display: none"></div>
|
||||
<div id="sfwdt{{ token }}" class="sf-toolbar sf-display-none"></div>
|
||||
{{ include('@WebProfiler/Profiler/base_js.html.twig') }}
|
||||
<script>/*<![CDATA[*/
|
||||
<script{% if csp_script_nonce %} nonce={{ csp_script_nonce }}{% endif %}>/*<![CDATA[*/
|
||||
(function () {
|
||||
{% if 'top' == position %}
|
||||
var sfwdt = document.getElementById('sfwdt{{ token }}');
|
||||
@ -14,6 +14,11 @@
|
||||
'sfwdt{{ token }}',
|
||||
'{{ path("_wdt", { "token": token }) }}',
|
||||
function(xhr, el) {
|
||||
|
||||
/* Evaluate embedded scripts inside the toolbar */
|
||||
var rx=/<script>(.*?)<\/script>/g, s;
|
||||
while (s = rx.exec(el.innerHTML)) {eval(s[1]);};
|
||||
|
||||
el.style.display = -1 !== xhr.responseText.indexOf('sf-toolbarreset') ? 'block' : 'none';
|
||||
|
||||
if (el.style.display == 'none') {
|
||||
@ -58,13 +63,38 @@
|
||||
}
|
||||
};
|
||||
}
|
||||
Sfjs.addEventListener(document.getElementById('sfToolbarHideButton-{{ token }}'), 'click', function () {
|
||||
event.preventDefault();
|
||||
|
||||
var p = this.parentNode;
|
||||
p.style.display = 'none';
|
||||
(p.previousElementSibling || p.previousSibling).style.display = 'none';
|
||||
document.getElementById('sfMiniToolbar-{{ token }}').style.display = 'block';
|
||||
Sfjs.setPreference('toolbar/displayState', 'none');
|
||||
});
|
||||
Sfjs.addEventListener(document.getElementById('sfToolbarMiniToggler-{{ token }}'), 'click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var elem = this.parentNode;
|
||||
if (elem.style.display == 'none') {
|
||||
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'none';
|
||||
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'none';
|
||||
elem.style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'block';
|
||||
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'block';
|
||||
elem.style.display = 'none'
|
||||
}
|
||||
|
||||
Sfjs.setPreference('toolbar/displayState', 'block');
|
||||
})
|
||||
},
|
||||
function(xhr) {
|
||||
if (xhr.status !== 0) {
|
||||
confirm('An error occurred while loading the web debug toolbar (' + xhr.status + ': ' + xhr.statusText + ').\n\nDo you want to open the profiler?') && (window.location = '{{ path("_profiler", { "token": token }) }}');
|
||||
}
|
||||
},
|
||||
{'maxTries': 5}
|
||||
{ maxTries: 5 }
|
||||
);
|
||||
})();
|
||||
/*]]>*/</script>
|
||||
|
@ -12,6 +12,7 @@
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Tests\Controller;
|
||||
|
||||
use Symfony\Bundle\WebProfilerBundle\Controller\ProfilerController;
|
||||
use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@ -44,17 +45,17 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testReturns404onTokenNotFound()
|
||||
/**
|
||||
* @dataProvider provideCspVariants
|
||||
*/
|
||||
public function testReturns404onTokenNotFound($withCsp)
|
||||
{
|
||||
$urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface');
|
||||
$twig = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock();
|
||||
$profiler = $this
|
||||
->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$controller = new ProfilerController($urlGenerator, $profiler, $twig, array());
|
||||
|
||||
$profiler
|
||||
->expects($this->exactly(2))
|
||||
->method('loadProfile')
|
||||
@ -65,6 +66,8 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase
|
||||
}))
|
||||
;
|
||||
|
||||
$controller = $this->createController($profiler, $twig, $withCsp);
|
||||
|
||||
$response = $controller->toolbarAction(Request::create('/_wdt/found'), 'found');
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
@ -72,16 +75,18 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSearchResult()
|
||||
/**
|
||||
* @dataProvider provideCspVariants
|
||||
*/
|
||||
public function testSearchResult($withCsp)
|
||||
{
|
||||
$urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface');
|
||||
$twig = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock();
|
||||
$profiler = $this
|
||||
->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$controller = new ProfilerController($urlGenerator, $profiler, $twig, array());
|
||||
$controller = $this->createController($profiler, $twig, $withCsp);
|
||||
|
||||
$tokens = array(
|
||||
array(
|
||||
@ -109,10 +114,10 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase
|
||||
->will($this->returnValue($tokens));
|
||||
|
||||
$request = Request::create('/_profiler/empty/search/results', 'GET', array(
|
||||
'limit' => 2,
|
||||
'ip' => '127.0.0.1',
|
||||
'method' => 'GET',
|
||||
'url' => 'http://example.com/',
|
||||
'limit' => 2,
|
||||
'ip' => '127.0.0.1',
|
||||
'method' => 'GET',
|
||||
'url' => 'http://example.com/',
|
||||
));
|
||||
|
||||
$twig->expects($this->once())
|
||||
@ -135,4 +140,25 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase
|
||||
$response = $controller->searchResultsAction($request, 'empty');
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function provideCspVariants()
|
||||
{
|
||||
return array(
|
||||
array(true),
|
||||
array(false),
|
||||
);
|
||||
}
|
||||
|
||||
private function createController($profiler, $twig, $withCSP)
|
||||
{
|
||||
$urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface');
|
||||
|
||||
if ($withCSP) {
|
||||
$nonceGenerator = $this->getMock('Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator');
|
||||
|
||||
return new ProfilerController($urlGenerator, $profiler, $twig, array(), 'normal', new ContentSecurityPolicyHandler($nonceGenerator));
|
||||
}
|
||||
|
||||
return new ProfilerController($urlGenerator, $profiler, $twig, array(), 'normal');
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Tests\Csp;
|
||||
|
||||
use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ContentSecurityPolicyHandlerTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideRequestAndResponses
|
||||
*/
|
||||
public function testGetNonces($nonce, $expectedNonce, Request $request, Response $response)
|
||||
{
|
||||
$cspHandler = new ContentSecurityPolicyHandler($this->mockNonceGenerator($nonce));
|
||||
|
||||
$this->assertSame($expectedNonce, $cspHandler->getNonces($request, $response));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideRequestAndResponsesForOnKernelResponse
|
||||
*/
|
||||
public function testOnKernelResponse($nonce, $expectedNonce, Request $request, Response $response, array $expectedCsp)
|
||||
{
|
||||
$cspHandler = new ContentSecurityPolicyHandler($this->mockNonceGenerator($nonce));
|
||||
|
||||
$this->assertSame($expectedNonce, $cspHandler->updateResponseHeaders($request, $response));
|
||||
|
||||
$this->assertFalse($response->headers->has('X-SymfonyProfiler-Script-Nonce'));
|
||||
$this->assertFalse($response->headers->has('X-SymfonyProfiler-Style-Nonce'));
|
||||
|
||||
foreach ($expectedCsp as $header => $value) {
|
||||
$this->assertSame($value, $response->headers->get($header));
|
||||
}
|
||||
}
|
||||
|
||||
public function provideRequestAndResponses()
|
||||
{
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
|
||||
$requestScriptNonce = 'request-with-headers-script-nonce';
|
||||
$requestStyleNonce = 'request-with-headers-style-nonce';
|
||||
|
||||
$responseScriptNonce = 'response-with-headers-script-nonce';
|
||||
$responseStyleNonce = 'response-with-headers-style-nonce';
|
||||
|
||||
$requestNonceHeaders = array(
|
||||
'X-SymfonyProfiler-Script-Nonce' => $requestScriptNonce,
|
||||
'X-SymfonyProfiler-Style-Nonce' => $requestStyleNonce,
|
||||
);
|
||||
$responseNonceHeaders = array(
|
||||
'X-SymfonyProfiler-Script-Nonce' => $responseScriptNonce,
|
||||
'X-SymfonyProfiler-Style-Nonce' => $responseStyleNonce,
|
||||
);
|
||||
|
||||
return array(
|
||||
array($nonce, array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), $this->createRequest(), $this->createResponse()),
|
||||
array($nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), $this->createRequest($requestNonceHeaders), $this->createResponse($responseNonceHeaders)),
|
||||
array($nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), $this->createRequest($requestNonceHeaders), $this->createResponse()),
|
||||
array($nonce, array('csp_script_nonce' => $responseScriptNonce, 'csp_style_nonce' => $responseStyleNonce), $this->createRequest(), $this->createResponse($responseNonceHeaders)),
|
||||
);
|
||||
}
|
||||
|
||||
public function provideRequestAndResponsesForOnKernelResponse()
|
||||
{
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
|
||||
$requestScriptNonce = 'request-with-headers-script-nonce';
|
||||
$requestStyleNonce = 'request-with-headers-style-nonce';
|
||||
|
||||
$responseScriptNonce = 'response-with-headers-script-nonce';
|
||||
$responseStyleNonce = 'response-with-headers-style-nonce';
|
||||
|
||||
$requestNonceHeaders = array(
|
||||
'X-SymfonyProfiler-Script-Nonce' => $requestScriptNonce,
|
||||
'X-SymfonyProfiler-Style-Nonce' => $requestStyleNonce,
|
||||
);
|
||||
$responseNonceHeaders = array(
|
||||
'X-SymfonyProfiler-Script-Nonce' => $responseScriptNonce,
|
||||
'X-SymfonyProfiler-Style-Nonce' => $responseStyleNonce,
|
||||
);
|
||||
|
||||
return array(
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(),
|
||||
array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce),
|
||||
$this->createRequest($requestNonceHeaders),
|
||||
$this->createResponse($responseNonceHeaders),
|
||||
array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce),
|
||||
$this->createRequest($requestNonceHeaders),
|
||||
$this->createResponse(),
|
||||
array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $responseScriptNonce, 'csp_style_nonce' => $responseStyleNonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse($responseNonceHeaders),
|
||||
array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('Content-Security-Policy' => 'default-src \'self\' domain.com; script-src \'self\' \'unsafe-inline\'')),
|
||||
array('Content-Security-Policy' => 'default-src \'self\' domain.com; script-src \'self\' \'unsafe-inline\'; style-src \'self\' domain.com \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'')),
|
||||
array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('Content-Security-Policy' => 'script-src \'self\'; style-src \'self\'')),
|
||||
array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'')),
|
||||
array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\'')),
|
||||
array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'sha384-LALALALALAAL\'')),
|
||||
array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'sha384-LALALALALAAL\' \'nonce-'.$nonce.'\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null),
|
||||
),
|
||||
array(
|
||||
$nonce,
|
||||
array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce),
|
||||
$this->createRequest(),
|
||||
$this->createResponse(array('Content-Security-Policy' => 'script-src \'self\'; style-src \'self\'', 'X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'self\'')),
|
||||
array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\''),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function createRequest(array $headers = array())
|
||||
{
|
||||
$request = new Request();
|
||||
$request->headers->add($headers);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function createResponse(array $headers = array())
|
||||
{
|
||||
$response = new Response();
|
||||
$response->headers->add($headers);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function mockNonceGenerator($value)
|
||||
{
|
||||
$generator = $this->getMock('Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator');
|
||||
|
||||
$generator->expects($this->any())
|
||||
->method('generate')
|
||||
->will($this->returnValue($value));
|
||||
|
||||
return $generator;
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
namespace Symfony\Bundle\WebProfilerBundle\Tests\EventListener;
|
||||
|
||||
use Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener;
|
||||
use Symfony\Component\HttpFoundation\HeaderBag;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
@ -31,7 +32,7 @@ class WebDebugToolbarListenerTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$response = new Response($content);
|
||||
|
||||
$m->invoke($listener, $response, Request::create('/'));
|
||||
$m->invoke($listener, $response, Request::create('/'), array('csp_script_nonce' => 'scripto', 'csp_style_nonce' => 'stylo'));
|
||||
$this->assertEquals($expected, $response->getContent());
|
||||
}
|
||||
|
||||
@ -243,6 +244,8 @@ class WebDebugToolbarListenerTest extends \PHPUnit_Framework_TestCase
|
||||
->method('getRequestFormat')
|
||||
->will($this->returnValue($requestFormat));
|
||||
|
||||
$request->headers = new HeaderBag();
|
||||
|
||||
if ($hasSession) {
|
||||
$session = $this->getMock('Symfony\Component\HttpFoundation\Session\Session', array(), array(), '', false);
|
||||
$request->expects($this->any())
|
||||
|
@ -18,6 +18,7 @@
|
||||
"require": {
|
||||
"php": ">=5.5.9",
|
||||
"symfony/http-kernel": "~2.8|~3.0",
|
||||
"symfony/polyfill-php70": "~1.0",
|
||||
"symfony/routing": "~2.8|~3.0",
|
||||
"symfony/twig-bridge": "~2.8|~3.0"
|
||||
},
|
||||
|
Reference in New Issue
Block a user