Slug v url adrese a jeho bezpečnost

Webster.K
Člen | 213
+
0
-

Zdravím všechny, potřebuji na web přidat část, kdy budu zobrazovat články z databáze na základě jeho názvu (slug). Upravil jsem si router:

$router->addRoute('navody/<slug>', [
            'presenter' => 'Article',
            'action' => 'show',
            'category' => [
                Route::VALUE => 'rady'
            ],
        ]);
        $router->addRoute('<presenter>/<action>[/<id>]', 'Home:default');

Potom jsem přidal ArticlePresenter s následujícím kodem:

declare(strict_types=1);

namespace App\UI\Article;

use Nette;
use Nette\Application\BadRequestException;
use Nette\Application\UI\Form;

final class ArticlePresenter extends \App\UI\Home\BasePresenter {
    public function renderShow(string $category, string $slug): void
    {
        $article = $this->findBySlug($slug);
        if (!$article ){
            throw new BadRequestException('Článek nenalezen.');
        }
        $this->template->article = $article;
    }

    public function findBySlug(string $slug): ?\Nette\Database\Table\ActiveRow
    {
        return $this->database->table('articles')->where('slug', $slug)->fetch();
    }

}

A v šabloně už zobrazuju jen stylem: {$article->content|noescape}. To co řeším je, jak je to u něčeho takového s bezpečností. Vše funguje tak jak má, tak jak jsem potřeboval a vlastně s kodem problém není. To co ale netuším, jestli jsem si tímto kódem, který hledá v databázi nezadělal na nějaký větší problém a časem by se nemohlo v aplikaci nečeho jiného zneužít. Většinou jsem v aplikaci měl max číslo které se ověřovalo poměrně jednoduše.

Kamil Valenta
Člen | 824
+
+1
-

Ne, je to v pohodě, protože „->where(‚slug‘, $slug)“ je bezpečná konstrukce.

Metoda findBySlug by ideálně neměla být v presenteru, ale modelové třídě. Ale není to problém bezpečnosti.

No a pak už je to spíš věc názoru. Já razím cestu, že router nemá pustit, co neexistuje. Takže slugy filtruju už v něm, protože ty teď matchneš něco, v presenteru article nedohledáš, vykopneš exceptionu… ale v seznamu rout mohla být dál jiná, která by ten slug legitimně matchla…

m.brecher
Generous Backer | 880
+
+1
-

@KamilValenta

Já razím cestu, že router nemá pustit, co neexistuje.

Dělám to naopak, router pouze ověřuje existenci presenteru, platnost akce a id záznamu definuje presenter.

v seznamu rout mohla být dál jiná, která by ten slug legitimně matchla

snažím se kaskádovitému matchování vyhnout, u malé aplikace se to dá nějak udržet pod kontrolou, ale jak počty presenterů rostou, je kaskádovité matchování zdrojem obtížně odhalitelných chyb. Za mě je ideální match 1 : 1. V reálném životě to vždycky nejde, ale snažím se to tak mít.

Jak jsem k tomuto konceptu dospěl?

Dlouhým zkoušením. Aplikace hodně dolaďuji a měním a vadí mě závislost mezi tím co definuje presenter – akce a parametry, především $id + $slug a tím, že router také může definovat povolené akce, tvar parametrů apod. Při změně v presenteru jsem pak musel vždy ručně řešit soulad mezi tím co definuje presenter a tím, co definuje router. Proto jsem nakonec striktně rozdělil kompetence mezi router a presenter – co dělá jeden nedělá druhý a naopak a život je hned o hodně jednodušší.

Martin Dřímal
Člen | 23
+
0
-

m.brecher napsal(a):
Dělám to naopak, router pouze ověřuje existenci presenteru, platnost akce a id záznamu definuje presenter.

Taky mě to hned napadlo… Router má rozhodovat pouze o tom, na který presenter požadavek pošle. Nepotřebuje vědět nic o tom, co s tím requestem presenter udělá, jesti někde existuje nějaký objekt, nebo jestli k němu má uživatel přístup, atd.. Stejně tak se mi osvědčilo psát routy tak, aby byly unikátní a nestávalo se, že to matchne 2 routy najednou (kromě nějakého default)

Editoval Martin Dřímal (24. 1. 16:08)

Kamil Valenta
Člen | 824
+
0
-

m.brecher napsal(a):

u malé aplikace se to dá nějak udržet pod kontrolou, ale jak počty presenterů rostou, je kaskádovité matchování zdrojem obtížně odhalitelných chyb.

Jakých? Naopak. Když se striktně dodržuje, že routa nematchne co jí nepatří, tak všem chybám předejdeš. Velikost projektu nehraje roli.

V reálném životě to vždycky nejde, ale snažím se to tak mít.

Přesně tak. V reálném životě klient řekne, že takovou URL potřebuje. Nebo jeho externí SEO agentura. A Ty přece neřekneš „ne, mně se to v systému líbí jinak“.

Aplikace, resp. odkazy a redirecty v ní, fungují bez vědomí, jak vypadají (nebo budou vypadat) URL. O to (jediné) se právě stará router. S Presenterem si kompetence nijak nesdílí.

m.brecher
Generous Backer | 880
+
0
-

@WebsterK

Ještě na doplnění. Parametr ‘category’, který není v url umístěn, má nastavenu defaultní hodnotu ‘rady’, akce presenteru ho sice přebírá, ale nepoužívá. Pokud jsou články pro návody uloženy v jedné kategorii ‘rady’, potom parametr $category není v routě ani v presenteru potřeba a jednoduše se zadá v metodě pro výběr článků.

Pokud se články pro rady tahají z více kategorií, může se parametrem $category doplnit url třeba takto: /navody/rady/jak-neco-vyresit:

$router->addRoute('navody/<category>/<slug>', 'Article:default')

Parametry $slug a $category je potřeba v latte šabloně do odkazů dodat:

<a n:href="Home:default, slug: $article->slug, category: $article->category->slug">{$article->title}</a>

je ale zbytečné ručně dodávat do odkazů redundantní informaci o kategorii, protože Router si může pomocí filtrů sám v databázi příslušný slug kategorie dohledat – třeba takto:

final class RouterFactory
{
    private static array $products;

    public static function create(Nette\DI\Container $container): RouteList
    {
        $router = new RouteList;

         $router->addRoute('navody/<category>/<slug>', [
                'presenter' => ['value' => 'Article'],
                'action' => ['value' => 'default'],
                null => ['filterOut' => fn(array $parameters) =>  self::filterOut($parameters, $container)]
            ]);

	// .......

        return $router;
    }

    private static function filterOut(array $parameters, Container $container): array
    {
        $product = self::getProducts($container)[$parameters['slug']];
        $parameters['category'] = $product->category->slug;
        return $parameters;
    }

    private static function getProducts(Container $container): array
    {
        if(!isset(self::$products)){
            $productModel = $container->getByType(ProductModel::class);
            self::$products = $productModel->findAllBySlug();
        }
        return self::$products;
    }
}

Router potřebuje předat příslušný model – ideálně jako službu pomocí nette di. Protože je v Nette zvykem vytvářet router pomocí statické metody factory, předáme model (ArticleModel) do Routeru ručně v services.neon. Aby se modelová třída nevytvářela zbytečně v těch místech webu, kde není potřeba, předáme do routeru rovnou celý DI\Container a službu si vytáhneme metodou getByType(). Místo metody getByType() lze použít Nette accessor který poskytuje technicky lepší řešení kontroly použité služby.

Aby se minimalizoval počet dotazů do databáze, získáme z databáze všechny potřebné záznamy pro router najednou – zacachujeme si je – ve statické privátní propertě (Router je použit staticky !!) a pak si taháme jednotlivé řádky z této cache.

Jak automaticky doplnit do url parametr $category ? Využijeme filtr Nette Routeru- použijeme filter směrem ven tedy Router::FilterOut (zjednodušeně ‘filterOut’), a z hodnoty $article->slug odvodíme hodnotu $article->category->slug. Filter je použit s klíčem null – podle dokumentace podle klíče null Nette pozná, že má použít obecný filter, kdy se do callbacku předají všechny parametry requestu.

services.neon:

DI\Container je defaultně k dispozici jako služba @container:

services:
    router:
        factory: App\Router\RouterFactory::create(@container)

ArticleModel:

public function findAllBySlug(): array
{
    return $this->explorer->table('articles')->fetchPairs('slug');
}

Nyní můžeme psát v latte elegantní jednoduché linky:

<a n:href="Article:default, slug: $article->slug">{$article->title}</a>

Router iniciuje vytvoření objektu modelu a tedy i připojení k databázi pouze tehdy, kdy to skutečně potřebuje a potom udělá jeden jediný dotaz do databáze.

Infanticide0
Člen | 112
+
0
-

Já už dlouho používám tento postup, do presenteru mi jde vždy existující, platná, povolená, nesmazaná entita a navíc můžu tvořit odkazy jednoduše takhle:

<a n:href="Post:detail $post">{$post->name}</a>
m.brecher
Generous Backer | 880
+
0
-

@KamilValenta

Aplikace, resp. odkazy a redirecty v ní, fungují bez vědomí, jak vypadají (nebo budou vypadat) URL. O to (jediné) se právě stará router. S Presenterem si kompetence nijak nesdílí.

Pokud router mapuje parametry do cool tvaru v url a neřeší, jestli je akce/parametr validní nebo ne, tak to oba dva děláme stejně :).

Když se striktně dodržuje, že routa nematchne co jí nepatří …

Mám routy pro neveřejnou administraci nějak takhle:

$router->addRoute('<module>/<presenter>[/<action>[/<id>]]', ['action' => ['value' => 'default']])

Tato routa matchne skoro celou aplikaci ideálním systémem 1 : 1 bez kaskádování, jestli je akce/id validní rozhodne presenter.

Ve veřejné části webu tuto obecnou routu doplňuji routami, kde místo <id> je <slug>, popř. obojí a opatrně použiji kvůli kráse url i kaskádování. Ověření existence záznamu ale tak jako tak zase dělá presenter. SEO agentura je spokojená a rozdělení kompetencí router/presenter zůstávají pořád stejné !!

router nemá pustit, co neexistuje …

Bez dotazu do databáze nemůže router rozhodnout zda záznam existuje. Router může předběžně validovat regulárním výrazem (např. <id \d+>) ale existenci záznamu v databázi musí v presenter tak jako tak ověřit dotazem v modelu (databázi). Správně formulovaný sql dotaz je bezpečný a spolehlivě vyřeší i validaci, kterou dělá regulárním výrazem router. Používat regulární výrazy v routeru je tedy v běžných routách zbytečná práce navíc, regulární výrazy se ale hodí pro speciální případy.

m.brecher
Generous Backer | 880
+
0
-

@Infanticide0

Díky za hezké, elegantní řešení, ale používáš tam entitu databázové knihovny Doctrine. Nevím, zda by uvedený postup s filtry šel použít s knihovnou Nette Explorer, tam žádná entita není.

Infanticide0
Člen | 112
+
+1
-

m.brecher napsal(a):

@Infanticide0

Díky za hezké, elegantní řešení, ale používáš tam entitu databázové knihovny Doctrine. Nevím, zda by uvedený postup s filtry šel použít s knihovnou Nette Explorer, tam žádná entita není.

@m.brecher

Explorer taky umí “entity”, viz odkaz