Перейти до вмісту

Безпека та SEO у власному рушії блогу: що ми зробили і що це дало

Як ми захищаємо блог від атак і допомагаємо пошуковим системам його читати: валідація, очищення HTML, заголовки безпеки, структуровані дані, карта сайту та RSS. З кодом і результатами за 12 місяців.

АвторВідділ ІТ
  • 8 хв читання
  • 208 переглядів
Безпека та SEO у власному рушії блогу: що ми зробили і що це дало

Це остання стаття серії, і вона про дві речі, потрібні кожному веб-проєкту: безпеку та пошукову оптимізацію. Розповідаю, як ми захистили блог від атак і зробили так, щоб пошукові системи добре його читали.

Безпека шарами

Жоден окремий інструмент не робить сайт безпечним. Потрібно кілька перевірок, одна за одною. Почнемо з основ.

Валідація всіх вхідних даних

Перша лінія захисту: перевіряти все, що приходить у застосунок. Робимо це в класах Form Request з Laravel:

class StorePostRequest extends FormRequest
{
    public function authorize()
    {
        return $this->user()->can('create', Post::class);
    }
    
    public function rules()
    {
        $rules = [
            'author_id' => ['required', 'integer', 'exists:admin_blog_labels,id'],
            'preview_image' => ['required', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
            'scheduled_at' => ['nullable', 'date', 'after:now'],
            'categories' => ['required', 'array', 'min:1'],
            'categories.*' => ['integer', 'exists:admin_blog_labels,id'],
        ];
        
        // Валідація мультимовного контенту
        foreach(Country::$langs as $lang) {
            $rules["contents.{$lang}.title"] = [
                'required', 
                'string', 
                'min:3', 
                'max:255',
                new NoScriptTags() // Кастомне правило
            ];
            
            $rules["contents.{$lang}.slug"] = [
                'required', 
                'string', 
                'max:255', 
                Rule::unique('admin_blog_posts_contents', 'slug'),
                'regex:/^[a-zA-Z0-9\-_]+$/' // Тільки безпечні символи
            ];
            
            $rules["contents.{$lang}.content_html"] = [
                'required', 
                'string', 
                'min:50',
                new SafeHtml() // Перевірка на XSS
            ];
        }
        
        return $rules;
    }
    
    public function messages()
    {
        return [
            'preview_image.max' => 'The image size must not exceed 2MB.',
            'contents.*.slug.regex' => 'The slug may only contain letters, numbers, dashes and underscores.',
        ];
    }
}

Для HTML ми написали власне правило:

class SafeHtml implements Rule
{
    protected array $dangerousTags = [
        'script', 'iframe', 'object', 'embed', 'form', 
        'input', 'button', 'select', 'textarea'
    ];
    
    protected array $dangerousAttributes = [
        'onclick', 'onmouseover', 'onerror', 'onload',
        'javascript:', 'vbscript:', 'data:text/html'
    ];
    
    public function passes($attribute, $value)
    {
        // Перевірка на небезпечні теги
        foreach ($this->dangerousTags as $tag) {
            if (stripos($value, "<{$tag}") !== false) {
                return false;
            }
        }
        
        // Перевірка на небезпечні атрибути
        foreach ($this->dangerousAttributes as $attr) {
            if (stripos($value, $attr) !== false) {
                return false;
            }
        }
        
        return true;
    }
    
    public function message()
    {
        return 'The :attribute contains potentially unsafe HTML content.';
    }
}

Очищення HTML

Самої валідації мало. Перед збереженням ми додатково очищуємо HTML:

use HTMLPurifier;
use HTMLPurifier_Config;

class ContentSanitizer
{
    protected HTMLPurifier $purifier;
    
    public function __construct()
    {
        $config = HTMLPurifier_Config::createDefault();
        
        // Дозволені теги
        $config->set('HTML.Allowed', 
            'p,br,strong,em,u,i,strike,sub,sup,
            h1,h2,h3,h4,h5,h6,
            ul,ol,li,
            blockquote,pre,code,
            a[href|title|target],
            img[src|alt|width|height],
            table,thead,tbody,tr,td,th'
        );
        
        // Безпечні посилання
        $config->set('HTML.TargetBlank', true);
        $config->set('HTML.Nofollow', true);
        
        // Захист від XSS
        $config->set('Core.EscapeNonASCIICharacters', true);
        $config->set('Output.FlashCompat', true);
        
        $this->purifier = new HTMLPurifier($config);
    }
    
    public function sanitize(string $html): string
    {
        return $this->purifier->purify($html);
    }
    
    public function sanitizeForEmail(string $html): string
    {
        // Більш суворі правила для email
        $config = HTMLPurifier_Config::createDefault();
        $config->set('HTML.Allowed', 'p,br,strong,em,a[href]');
        $config->set('HTML.TidyLevel', 'heavy');
        
        $emailPurifier = new HTMLPurifier($config);
        return $emailPurifier->purify($html);
    }
}

CSRF і XSS

Laravel захищає від обох за замовчуванням. Ми додали заголовки безпеки:

// Middleware для додаткових заголовків безпеки
class SecurityHeaders
{
    public function handle($request, Closure $next)
    {
        $response = $next($request);
        
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        $response->headers->set('X-XSS-Protection', '1; mode=block');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        
        // Content Security Policy
        $csp = "default-src 'self'; " .
               "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " .
               "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " .
               "font-src 'self' https://fonts.gstatic.com; " .
               "img-src 'self' data: https:; " .
               "connect-src 'self' https://api.openai.com";
               
        $response->headers->set('Content-Security-Policy', $csp);
        
        return $response;
    }
}

Авторизація та політики доступу

Права доступу зберігаємо в Laravel Policies, окремий набір правил для кожної ролі:

class PostPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasAnyRole(['admin', 'moderator', 'author']);
    }
    
    public function view(User $user, Post $post): bool
    {
        // Адміни бачать все
        if ($user->hasRole('admin')) {
            return true;
        }
        
        // Автори бачать тільки свої пости
        if ($user->hasRole('author')) {
            return $post->creator_id === $user->id;
        }
        
        // Модератори бачать опубліковані та на модерації
        if ($user->hasRole('moderator')) {
            return $post->published_at !== null || $post->drafted_at !== null;
        }
        
        return false;
    }
    
    public function create(User $user): bool
    {
        return $user->hasAnyRole(['admin', 'author']);
    }
    
    public function update(User $user, Post $post): bool
    {
        if ($user->hasRole('admin')) {
            return true;
        }
        
        if ($user->hasRole('author')) {
            return $post->creator_id === $user->id && 
                   $post->published_at === null;
        }
        
        return false;
    }
    
    public function delete(User $user, Post $post): bool
    {
        return $user->hasRole('admin') || 
               ($user->hasRole('author') && 
                $post->creator_id === $user->id && 
                $post->published_at === null);
    }
}

SEO: контент, зрозумілий пошуковикам

Тепер SEO. Мета проста: пошукова система має розуміти, про що кожна сторінка, без здогадок.

Структуровані дані (Schema.org)

Додаємо мікророзмітку, щоб пошуковики могли прочитати дані статті напряму:

class SchemaGenerator
{
    public function generateArticleSchema(Post $post, Content $content): array
    {
        return [
            "@context" => "https://schema.org",
            "@type" => "Article",
            "headline" => $content->title,
            "description" => $content->excerpt,
            "image" => $post->image_full_absolute_path,
            "datePublished" => $post->published_at->toIso8601String(),
            "dateModified" => $post->updated_at->toIso8601String(),
            "author" => [
                "@type" => "Person",
                "name" => $post->author->name,
                "url" => route('blog.author', $post->author->id)
            ],
            "publisher" => [
                "@type" => "Organization",
                "name" => "IT Compass",
                "logo" => [
                    "@type" => "ImageObject",
                    "url" => asset('images/logo.png')
                ]
            ],
            "mainEntityOfPage" => [
                "@type" => "WebPage",
                "@id" => route('blog.show', [$content->language, $content->slug])
            ],
            "keywords" => implode(', ', $content->keywords),
            "articleSection" => $post->categories->pluck('name')->implode(', '),
            "wordCount" => str_word_count(strip_tags($content->content_html))
        ];
    }
}

У шаблоні:

<script type="application/ld+json">
    {!! json_encode($schemaData, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>

Метатеги

Усі метатеги на сторінці задає один компонент:

<template>
  <Head>
    <title>{{ metaTitle }}</title>
    <meta name="description" :content="metaDescription" />
    <meta name="keywords" :content="metaKeywords" />
    
    <!-- Open Graph -->
    <meta property="og:type" content="article" />
    <meta property="og:title" :content="ogTitle" />
    <meta property="og:description" :content="ogDescription" />
    <meta property="og:image" :content="ogImage" />
    <meta property="og:url" :content="canonicalUrl" />
    
    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" :content="twitterTitle" />
    <meta name="twitter:description" :content="twitterDescription" />
    <meta name="twitter:image" :content="twitterImage" />
    
    <!-- Canonical URL -->
    <link rel="canonical" :href="canonicalUrl" />
    
    <!-- Альтернативні мови -->
    <link 
      v-for="lang in alternateLanguages" 
      :key="lang.code"
      rel="alternate" 
      :hreflang="lang.code" 
      :href="lang.url" 
    />
  </Head>
</template>

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';

const props = defineProps<{
  post: Post;
  content: Content;
  currentLanguage: string;
}>();

const metaTitle = computed(() => {
  const siteName = 'IT Compass Blog';
  return props.content.meta_title 
    ? `${props.content.meta_title} | ${siteName}`
    : `${props.content.title} | ${siteName}`;
});

const metaDescription = computed(() => {
  return props.content.meta_description || 
         props.content.excerpt.substring(0, 160);
});

const canonicalUrl = computed(() => {
  return route('blog.show', [
    props.currentLanguage, 
    props.content.slug
  ]);
});

const alternateLanguages = computed(() => {
  return props.post.contents.map(content => ({
    code: content.language,
    url: route('blog.show', [content.language, content.slug])
  }));
});
</script>

Структура адрес

Адреси зрозумілі людині й містять мову:

// routes/web.php
Route::prefix('blog')->group(function () {
    // Список постів з мовою
    Route::get('/{language}', [BlogController::class, 'index'])
        ->where('language', 'en|pl|uk')
        ->name('blog.index');
    
    // Перегляд поста
    Route::get('/{language}/{slug}', [BlogController::class, 'show'])
        ->where('language', 'en|pl|uk')
        ->name('blog.show');
    
    // Категорії
    Route::get('/{language}/category/{category}', [BlogController::class, 'category'])
        ->where('language', 'en|pl|uk')
        ->name('blog.category');
    
    // Автор
    Route::get('/author/{author}', [BlogController::class, 'author'])
        ->name('blog.author');
});

// Автоматичний редірект на мову користувача
Route::get('/blog', function () {
    $userLang = request()->getPreferredLanguage(['en', 'pl', 'uk']);
    return redirect()->route('blog.index', ['language' => $userLang ?? 'en']);
});

Карта сайту XML

Карта сайту генерується на запит:

class SitemapController extends Controller
{
    public function index()
    {
        $sitemap = Sitemap::create();
        
        // Головні сторінки
        $sitemap->add(Url::create('/')->setPriority(1.0));
        $sitemap->add(Url::create('/blog')->setPriority(0.9));
        
        // Пости для кожної мови
        $posts = Post::published()->with('contents')->get();
        
        foreach ($posts as $post) {
            foreach ($post->contents as $content) {
                $url = Url::create(route('blog.show', [
                    $content->language, 
                    $content->slug
                ]))
                ->setLastModificationDate($post->updated_at)
                ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
                ->setPriority(0.8);
                
                // Додаємо альтернативні мови
                foreach ($post->contents as $altContent) {
                    if ($altContent->language !== $content->language) {
                        $url->addAlternate(
                            route('blog.show', [$altContent->language, $altContent->slug]),
                            $altContent->language
                        );
                    }
                }
                
                $sitemap->add($url);
            }
        }
        
        // Категорії
        $categories = Label::categories()->withCount('posts')->get();
        foreach ($categories as $category) {
            if ($category->posts_count > 0) {
                foreach (['en', 'pl', 'uk'] as $lang) {
                    $sitemap->add(
                        Url::create(route('blog.category', [$lang, $category->id]))
                            ->setPriority(0.6)
                    );
                }
            }
        }
        
        return $sitemap;
    }
}

Стрічки RSS та Atom

Стрічка працює на пакеті spatie/laravel-feed:

// config/feed.php
return [
    'feeds' => [
        'main' => [
            'items' => [Post::class, 'getFeedItems'],
            'url' => '/blog/feed',
            'title' => 'IT Compass Blog',
            'description' => 'Latest articles from IT Compass',
            'language' => 'en-US',
            'view' => 'feed::atom',
            'type' => 'application/atom+xml',
        ],
    ],
];

// У моделі Post
public function toFeedItem(): FeedItem
{
    $content = $this->localContent;
    
    return FeedItem::create([
        'id' => $this->id,
        'title' => $content->title,
        'summary' => $content->excerpt,
        'updated' => $this->published_at ?? $this->created_at,
        'link' => url("/blog/{$content->language}/{$content->slug}"),
        'authorName' => $this->author->name,
        'category' => $this->categories->pluck('name')->toArray(),
    ]);
}

public static function getFeedItems()
{
    return Cache::remember('feed_items', now()->addMinutes(10), function () {
        return self::published()
            ->with(['author', 'categories'])
            ->latest('published_at')
            ->take(20)
            ->get();
    });
}

Підписки

Підписка на розсилку з перевіркою на спам:

class SubscriberController extends Controller
{
    public function store(StoreSubscriberRequest $request)
    {
        // Перевірка на спам
        if ($this->isSpamRequest($request)) {
            return response()->json(['message' => 'Thank you!'], 200);
        }
        
        DB::transaction(function () use ($request) {
            $subscriber = Subscriber::create([
                'email' => $request->validated()['email'],
                'ip_address' => $request->ip(),
                'language' => app()->getLocale(),
                'confirmed' => false,
                'token' => Str::random(32),
            ]);
            
            // Відправка підтвердження
            Mail::to($subscriber->email)->queue(
                new ConfirmSubscriptionMail($subscriber)
            );
        });
        
        return response()->json([
            'message' => __('blog.subscription_confirm_sent')
        ]);
    }
    
    protected function isSpamRequest($request): bool
    {
        // Перевірка honeypot поля
        if ($request->filled('website')) {
            return true;
        }
        
        // Перевірка частоти запитів з IP
        $recentAttempts = Subscriber::where('ip_address', $request->ip())
            ->where('created_at', '>', now()->subMinutes(5))
            ->count();
            
        return $recentAttempts >= 3;
    }
}

Моніторинг безпеки

Підозрілі запити потрапляють у лог, а критичні ще й викликають лист:

class SecurityMonitor
{
    public static function logSuspiciousActivity(string $type, array $data)
    {
        DB::table('security_logs')->insert([
            'type' => $type,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'user_id' => auth()->id(),
            'data' => json_encode($data),
            'created_at' => now(),
        ]);
        
        // Алерт при критичних подіях
        if (in_array($type, ['sql_injection_attempt', 'xss_attempt'])) {
            Mail::to(config('security.alert_email'))->queue(
                new SecurityAlertMail($type, $data)
            );
        }
    }
}

Результати

Що ми побачили після впровадження всього цього:

  • Жодної успішної атаки за 12 місяців роботи

  • Органічний трафік зріс на 280% за 6 місяців

  • Середня позиція в пошуковій видачі: 12, раніше було 47

  • Нові записи індексуються менш ніж за 24 години

  • Core Web Vitals усі в зеленій зоні

Консольні команди

# Сканування безпеки
php artisan security:scan

# Перевірка SEO
php artisan seo:analyze

# Моніторинг продуктивності
php artisan monitor:performance

До чого зводиться вся серія

  1. Архітектура задає межі. Міцний фундамент згодом економить місяці роботи.

  2. Автоматизація залишає більше часу на сам контент.

  3. Швидкодія теж функція: швидкий сайт краще конвертує.

  4. Добрий UX розв'язує бізнес-завдання, а зручність видно в метриках.

  5. Безпека та SEO коштують найменше, коли закладені з самого початку.

Чи окупилася власна CMS?

У нашому випадку так. Це була важка робота, але ми отримали систему, яка:

  • робить саме те, що нам потрібно

  • росте разом із бізнесом

  • дає перевагу над конкурентами

  • показує, що ми вміємо технічно

Сподіваюся, серія була корисною. Найкращий код той, що розв'язує справжню бізнес-задачу.

Дякую, що прочитали всю серію. Питання можна лишати в коментарях.


CEO IT Compass

Теги

Поділитися статтею

Яку LLM обрати для бізнесу: GPT, Claude, Gemini чи open-source

Сильні сторони GPT, Claude, Gemini та open-source моделей, підказки, коли обирати кожну, і шість кроків вибору з урахуванням вартості, безпеки даних та інтеграції.

  • 10 хв читання
  • 219 переглядів

AI-агенти в компанії: де вони окупаються, а де вистачить скрипта

Чим AI-агент відрізняється від чат-бота й RPA, у яких процесах він дає вимірювану віддачу і чому більшість пілотів не доходить до продакшну. У кінці тест із п'яти запитань перед стартом проєкту.

  • 9 хв читання
  • 202 перегляди

Чому ШІ посилює хаос у невпорядкованих компаніях

ШІ примножує все, що знаходить у компанії, зокрема безлад у даних і незадокументовані процеси. Розбираємо, чому впровадження провалюються і в якій послідовності діяти, щоб воно окупилося.

  • 9 хв читання
  • 143 перегляди
Повернутися до блогу

Файли cookies

Ми використовуємо необхідні файли cookies та один функціональний, який вмикаємо лише за вашою згодою. Статистику відвідувань рахуємо на сервері, без cookies. Політика cookies