Add @signedGraphTag() and @signedGraphUrl() blade directives (#14269)

* More secure external graph access
Add @signedGraphTag() and @signedGraphUrl() blade directives
Takes either an array of graph variables or a url to a graph
Uses a signed url that is accessible without user login, embeds signature in url to authenticate access
See Laravel Signed Url for more details.
Adds Laravel route to graphs (does not change links to use it yet)
@graphImage requires the other PR
Also APP_URL is required in .env

* missing files from rebase

* Fix url parsing with a get string

* allow width and height to be omitted

* Documentation

* Add to, otherwise it will always be now

* Doc note for to and from relative security

* fix vars.inc.php (Laravel has a dummy url here)
This commit is contained in:
Tony Murray 2022-09-03 12:48:43 -05:00 committed by GitHub
parent a95b1c408c
commit 5c76890373
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 293 additions and 57 deletions

View File

@ -32,6 +32,7 @@ use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use LibreNMS\Config;
use Request;
use Symfony\Component\HttpFoundation\ParameterBag;
class Url
@ -324,6 +325,20 @@ class Url
return $url;
}
/**
* @param array|string $args
*/
public static function forExternalGraph($args): string
{
// handle pasted string
if (is_string($args)) {
$path = str_replace(url('/') . '/', '', $args);
$args = self::parseLegacyPathVars($path);
}
return \URL::signedRoute('graph', $args);
}
/**
* @param array $args
* @return string
@ -584,6 +599,57 @@ class Url
return is_null($key) ? $options : $options[$key] ?? $default;
}
/**
* Parse variables from legacy path /key=value/key=value or regular get/post variables
*/
public static function parseLegacyPathVars(?string $path = null): array
{
$vars = [];
$parsed_get_vars = [];
if (empty($path)) {
$path = Request::path();
} elseif (Str::startsWith($path, 'http') || str_contains($path, '?')) {
$parsed_url = parse_url($path);
$path = $parsed_url['path'] ?? '';
parse_str($parsed_url['query'] ?? '', $parsed_get_vars);
}
// don't parse the subdirectory, if there is one in the path
$base_url = parse_url(Config::get('base_url'))['path'] ?? '';
if (strlen($base_url) > 1) {
$segments = explode('/', trim(str_replace($base_url, '', $path), '/'));
} else {
$segments = explode('/', trim($path, '/'));
}
// parse the path
foreach ($segments as $pos => $segment) {
$segment = urldecode($segment);
if ($pos === 0) {
$vars['page'] = $segment;
} else {
[$name, $value] = array_pad(explode('=', $segment), 2, null);
if (! $value) {
if ($vars['page'] == 'device' && $pos < 3) {
// translate laravel device routes properly
$vars[$pos === 1 ? 'device' : 'tab'] = $name;
} elseif ($name) {
$vars[$name] = 'yes';
}
} else {
$vars[$name] = $value;
}
}
}
$vars = array_merge($vars, $parsed_get_vars);
// don't leak login data
unset($vars['username'], $vars['password']);
return $vars;
}
private static function escapeBothQuotes($string)
{
return str_replace(["'", '"'], "\'", $string);

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use LibreNMS\Config;
use LibreNMS\Util\Debug;
use LibreNMS\Util\Url;
class GraphController extends Controller
{
public function __invoke(Request $request, string $path = ''): Response
{
define('IGNORE_ERRORS', true);
include_once base_path('includes/dbFacile.php');
include_once base_path('includes/common.php');
include_once base_path('includes/html/functions.inc.php');
include_once base_path('includes/rewrites.php');
$auth = \Auth::guest(); // if user not logged in, assume we authenticated via signed url, allow_unauth_graphs or allow_unauth_graphs_cidr
$vars = array_merge(Url::parseLegacyPathVars($request->path()), $request->except(['username', 'password']));
if (\Auth::check()) {
// only allow debug for logged in users
Debug::set(! empty($vars['debug']));
}
// TODO, import graph.inc.php code and call Rrd::graph() directly
chdir(base_path());
ob_start();
include base_path('includes/html/graphs/graph.inc.php');
$output = ob_get_clean();
ob_end_clean();
$headers = [];
if (! Debug::isEnabled()) {
$headers['Content-type'] = (Config::get('webui.graph_type') == 'svg' ? 'image/svg+xml' : 'image/png');
}
return response($output, 200, $headers);
}
}

View File

@ -0,0 +1,100 @@
<?php
/*
* AuthenticateGraph.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2022 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use LibreNMS\Config;
use LibreNMS\Exceptions\InvalidIpException;
use LibreNMS\Util\IP;
class AuthenticateGraph
{
/** @var string[] */
protected $auth = [
\App\Http\Middleware\LegacyExternalAuth::class,
\App\Http\Middleware\Authenticate::class,
\App\Http\Middleware\VerifyTwoFactor::class,
\App\Http\Middleware\LoadUserPreferences::class,
];
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $relative
* @return \Illuminate\Http\Response
*
* @throws \Illuminate\Auth\AuthenticationException
*/
public function handle($request, Closure $next, $relative = null)
{
// if user is logged in, allow
if (\Auth::check()) {
return $next($request);
}
// bypass normal auth if signed
if ($request->hasValidSignature($relative !== 'relative')) {
return $next($request);
}
// bypass normal auth if ip is allowed (or all IPs)
if ($this->isAllowed($request)) {
return $next($request);
}
// unauthenticated, force login
throw new AuthenticationException('Unauthenticated.');
}
protected function isAllowed(Request $request): bool
{
if (Config::get('allow_unauth_graphs', false)) {
d_echo("Unauthorized graphs allowed\n");
return true;
}
$ip = $request->getClientIp();
try {
$client_ip = IP::parse($ip);
foreach (Config::get('allow_unauth_graphs_cidr', []) as $range) {
if ($client_ip->inNetwork($range)) {
d_echo("Unauthorized graphs allowed from $range\n");
return true;
}
}
} catch (InvalidIpException $e) {
d_echo("Client IP ($ip) is invalid.\n");
}
return false;
}
}

View File

@ -23,7 +23,7 @@ class RedirectIfAuthenticated
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
return redirect()->intended(RouteServiceProvider::HOME);
}
}

View File

@ -70,6 +70,19 @@ class AppServiceProvider extends ServiceProvider
Blade::directive('deviceUrl', function ($arguments) {
return "<?php echo \LibreNMS\Util\Url::deviceUrl($arguments); ?>";
});
// Graphing
Blade::directive('signedGraphUrl', function ($vars) {
return "<?php echo \LibreNMS\Util\Url::forExternalGraph($vars); ?>";
});
Blade::directive('signedGraphTag', function ($vars) {
return "<?php echo '<img class=\"librenms-graph\" src=\"' . \LibreNMS\Util\Url::forExternalGraph($vars) . '\" />'; ?>";
});
Blade::directive('graphImage', function ($vars, $base64 = false) {
return "<?php echo \LibreNMS\Util\Graph::get(is_array($vars) ? $vars : \LibreNMS\Util\Url::parseLegacyPathVars($vars), $base64); ?>";
});
}
private function configureMorphAliases()

View File

@ -25,6 +25,7 @@ return [
'enabled' => env('DEBUGBAR_ENABLED', null),
'except' => [
'api*',
'graph*',
'push*',
],

View File

@ -284,12 +284,61 @@ Note: To use HTML emails you must set HTML email to Yes in the WebUI
under Global Settings > Alerting Settings > Email transport > Use HTML
emails
Note: To include Graphs you must enable unauthorized graphs in
config.php. Allow_unauth_graphs_cidr is optional, but more secure.
## Graphs
There are two helpers for graphs that will use a signed url to allow secure external
access. Anyone using the signed url will be able to view the graph. Your LibreNMS web
must be accessible from the location where the graph is viewed.
You may specify the graph one of two ways, a php array of parameters, or
a direct url to a graph.
Note that to and from can be specified either as timestamps with `time()`
or as relative time `-3d` or `-36h`. When using relative time, the graph
will show based on when the user views the graph, not when the event happened.
Sharing a graph image with a relative time will always give the recipient access
to current data, where a specific timestamp will only allow access to that timeframe.
### @signedGraphTag
This will insert a specially formatted html img tag linking to the graph.
Some transports may search the template for this tag to attach images properly
for that transport.
```
$config['allow_unauth_graphs_cidr'] = array('127.0.0.1/32');
$config['allow_unauth_graphs'] = true;
@signedGraphTag([
'id' => $value['port_id'],
'type' => 'port_bits',
'from' => time() - 43200,
'to' => time(),
'width' => 700,
'height' => 250
])
```
Output:
```html
<img class="librenms-graph" src="https://librenms.org/graph?from=1662176216&amp;height=250&amp;id=20425&amp;to=1662219416&amp;type=port_bits&amp;width=700&amp;signature=f6e516e8fd893c772eeaba165d027cb400e15a515254de561a05b63bc6f360a4">
```
Specific graph using url input:
```
@signedGraphTag('https://librenms.org/graph.php?type=device_processor&from=-2d&device=2&legend=no&height=400&width=1200')
```
### @signedGraphUrl
This is used when you need the url directly. One example is using the
API Transport, you may want to include the url only instead of a html tag.
```
@signedGraphUrl([
'id' => $value['port_id'],
'type' => 'port_bits',
'from' => time() - 43200,
'to' => time(),
])
```
## Using models for optional data
@ -355,7 +404,8 @@ Rule: @if ($alert->name) {{ $alert->name }} @else {{ $alert->rule }} @endif <br>
{{ $key }}: {{ $value['string'] }}<br>
@endforeach
@if ($alert->faults) <b>Faults:</b><br>
@foreach ($alert->faults as $key => $value)<img src="https://server/graph.php?device={{ $value['device_id'] }}&type=device_processor&width=459&height=213&lazy_w=552&from=end-72h"><br>
@foreach ($alert->faults as $key => $value)
@signedGraphTag(['device_id' => $value['device_id'], 'type' => 'device_processor', 'width' => 459, 'height' => 213, 'from' => time() - 259200])<br>
https://server/graphs/id={{ $value['device_id'] }}/type=device_processor/<br>
@endforeach
Template: CPU alert <br>

View File

@ -13,7 +13,7 @@ use LibreNMS\Util\Debug;
$auth = false;
$start = microtime(true);
$init_modules = ['web', 'graphs', 'auth'];
$init_modules = ['web', 'auth'];
require realpath(__DIR__ . '/..') . '/includes/init.php';
if (! Auth::check()) {

View File

@ -4,11 +4,6 @@ use LibreNMS\Config;
global $debug;
// Push $_GET into $vars to be compatible with web interface naming
foreach ($_GET as $name => $value) {
$vars[$name] = $value;
}
[$type, $subtype] = extract_graph_type($vars['type']);
if (isset($vars['device'])) {
@ -18,14 +13,14 @@ if (isset($vars['device'])) {
}
// FIXME -- remove these
$width = $vars['width'];
$height = $vars['height'];
$width = $vars['width'] ?? 400;
$height = $vars['height'] ?? round($width / 3);
$title = $vars['title'] ?? '';
$vertical = $vars['vertical'] ?? '';
$legend = $vars['legend'] ?? false;
$output = (! empty($vars['output']) ? $vars['output'] : 'default');
$from = empty($_GET['from']) ? Config::get('time.day') : parse_at_time($_GET['from']);
$to = empty($_GET['to']) ? Config::get('time.now') : parse_at_time($_GET['to']);
$from = empty($vars['from']) ? Config::get('time.day') : parse_at_time($vars['from']);
$to = empty($vars['to']) ? Config::get('time.now') : parse_at_time($vars['to']);
$period = ($to - $from);
$prev_from = ($from - $period);
@ -113,6 +108,10 @@ try {
echo $output === 'base64' ? base64_encode($image_data) : $image_data;
}
} catch (\LibreNMS\Exceptions\RrdGraphException $e) {
if (\LibreNMS\Util\Debug::isEnabled()) {
throw $e;
}
if (isset($rrd_filename) && ! Rrd::checkRrdExists($rrd_filename)) {
graph_error($width < 200 ? 'No Data' : 'No Data file ' . basename($rrd_filename));
} else {

View File

@ -1,46 +1,6 @@
<?php
use LibreNMS\Config;
foreach ($_GET as $key => $get_var) {
if (strstr($key, 'opt')) {
[$name, $value] = explode('|', $get_var);
if (! isset($value)) {
$value = 'yes';
}
$vars[$name] = strip_tags($value);
}
}
$base_url = parse_url(Config::get('base_url'));
$uri = explode('?', $_SERVER['REQUEST_URI'], 2)[0] ?? ''; // remove query, that is handled below with $_GET
// don't parse the subdirectory, if there is one in the path
if (isset($base_url['path']) && strlen($base_url['path']) > 1) {
$segments = explode('/', trim(str_replace($base_url['path'], '', $uri), '/'));
} else {
$segments = explode('/', trim($uri, '/'));
}
foreach ($segments as $pos => $segment) {
$segment = urldecode($segment);
if ($pos === 0) {
$vars['page'] = $segment;
} else {
[$name, $value] = array_pad(explode('=', $segment), 2, null);
if (! $value) {
if ($vars['page'] == 'device' && $pos < 3) {
// translate laravel device routes properly
$vars[$pos === 1 ? 'device' : 'tab'] = $name;
} else {
$vars[$name] = 'yes';
}
} else {
$vars[$name] = $value;
}
}
}
$vars = \LibreNMS\Util\Url::parseLegacyPathVars($_SERVER['REQUEST_URI']);
foreach ($_GET as $name => $value) {
$vars[$name] = strip_tags($value);

View File

@ -23,6 +23,10 @@ Route::prefix('auth')->name('socialite.')->group(function () {
Route::get('{provider}/metadata', [\App\Http\Controllers\Auth\SocialiteController::class, 'metadata'])->name('metadata');
});
Route::get('graph/{path?}', 'GraphController')
->where('path', '.*')
->middleware(['web', \App\Http\Middleware\AuthenticateGraph::class])->name('graph');
// WebUI
Route::group(['middleware' => ['auth'], 'guard' => 'auth'], function () {