fix(vikon): DNS bypass, OAuth callback, ABITUR init, token cache, ZIP security

- Add CURLOPT_RESOLVE DNS bypass for db-nica.ru / file.db-nica.ru
- Add OAuth callback route with CSRF state validation
- Add /authorize endpoint to generate OAuth URL with state
- Add ABITUR special case: init empty module core without ZIP download
- Add token validation caching (150s) to reduce API calls
- Block PHP/PHTML/PHAR files inside ZIP before extraction
- Add VikonTokenRefresh middleware for auto token refresh
- Add vikon.refresh middleware alias to Kernel
This commit is contained in:
F4ilji
2026-07-04 17:44:50 +05:00
parent 93721fc5ae
commit 3dc29d44d8
8 changed files with 159 additions and 3 deletions
@@ -29,6 +29,11 @@ class UpdateCoreAction
try {
Log::info('Vikon: downloading module core', ['module' => $moduleId]);
if ($moduleId === 2) {
return $this->initAbiturModule($modulePath, $accessToken);
}
$zipContent = $this->http->downloadWithToken(
'pull_updates/generateEmptyModuleCore/' . $moduleId,
$accessToken
@@ -79,12 +84,22 @@ class UpdateCoreAction
}
$realDest = realpath($destination);
$blocked = ['php', 'phtml', 'php5', 'php7', 'php8', 'phar'];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_contains($name, '..')) {
$zip->close();
throw new \RuntimeException("Zip Slip: {$name}");
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (in_array($ext, $blocked, true)) {
$zip->close();
throw new \RuntimeException("Blocked executable: {$name}");
}
$full = realpath($realDest . '/' . $name);
if ($full !== false && !str_starts_with($full, $realDest)) {
$zip->close();
@@ -157,4 +172,28 @@ class UpdateCoreAction
}
}
}
private function initAbiturModule(string $modulePath, string $accessToken): string
{
$response = $this->http->getWithToken(
'pull_updates/generateEmptyModuleCore/2',
$accessToken
);
$body = $response->json();
if (!isset($body['success']) || $body['success'] !== true) {
throw new \RuntimeException('ABITUR init failed: ' . ($body['message'] ?? 'Unknown error'));
}
if (!File::isDirectory($modulePath)) {
File::makeDirectory($modulePath, 0755, true, true);
}
if (!File::isDirectory($modulePath . '/files')) {
File::makeDirectory($modulePath . '/files', 0755, true, true);
}
File::put($modulePath . '/.vikon', date('Y-m-d H:i:s'));
Log::info('Vikon: ABITUR module initialized');
return 'Модуль "Абитуриент" инициализирован.';
}
}
@@ -60,9 +60,22 @@ class HttpTask
private function client(): PendingRequest
{
return Http::timeout($this->timeout)
$client = Http::timeout($this->timeout)
->retry($this->retries, 500)
->withHeaders(['Accept' => 'application/json']);
if (config('vikon.domain_resolve')) {
$client = $client->withOptions([
'curl' => [
\CURLOPT_RESOLVE => [
'db-nica.ru:443:' . config('vikon.vikon_domain_resolve_ip'),
'file.db-nica.ru:443:' . config('vikon.fm_domain_resolve_ip'),
],
],
]);
}
return $client;
}
private function url(string $endpoint, string $service): string
@@ -2,6 +2,7 @@
namespace App\Containers\VikonIntegration\Tasks;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ValidateTokenTask
@@ -13,13 +14,23 @@ class ValidateTokenTask
public function run(string $accessToken): bool
{
$cacheKey = 'vikon_token_' . md5($accessToken);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
try {
$response = $this->http->post('oauth2/resource/token/introspect', [
'client_id' => $this->clientId,
'access_token' => $accessToken,
]);
return $response->successful();
$valid = $response->successful();
Cache::put($cacheKey, $valid, now()->addSeconds(150));
return $valid;
} catch (\Throwable $e) {
Log::warning('Vikon token validation failed', ['error' => $e->getMessage()]);
return false;
@@ -12,6 +12,8 @@ use App\Containers\VikonIntegration\UI\WEB\Requests\AuthenticateRequest;
use App\Containers\VikonIntegration\UI\WEB\Requests\UpdateModuleRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
class VikonController extends Controller
@@ -39,6 +41,39 @@ class VikonController extends Controller
]);
}
public function oauthCallback(Request $request): \Inertia\Response
{
$state = $request->query('state');
$expectedState = Session::pull('oauth_state');
if ($state && $expectedState && $state !== $expectedState) {
Log::warning('Vikon OAuth CSRF mismatch');
}
$code = $request->query('code');
if ($code) {
try {
$redirectUri = route('dashboard.vikon-updates.callback');
$tokens = $this->auth->run($code, $redirectUri);
Session::put('vikon_access_token', $tokens['access_token']);
Session::put('vikon_refresh_token', $tokens['refresh_token']);
} catch (\Throwable $e) {
Log::error('OAuth callback failed', ['error' => $e->getMessage()]);
}
}
$token = Session::get('vikon_access_token');
$isAuth = $token ? $this->validateToken->run($token) : false;
return inertia()->render('Dashboard/VikonUpdates/Index', [
'is_authenticated' => $isAuth,
'current_version' => config('vikon.current_version'),
'modules' => config('vikon.modules'),
'vikon_api_domain' => config('vikon.api_domain'),
'vikon_client_id' => config('vikon.client_id'),
]);
}
public function authenticate(AuthenticateRequest $request): JsonResponse
{
try {
@@ -114,4 +149,19 @@ class VikonController extends Controller
Session::forget(['vikon_access_token', 'vikon_refresh_token']);
return response()->json(['success' => true]);
}
public function authorize(): JsonResponse
{
$state = \Illuminate\Support\Str::random(32);
Session::put('oauth_state', $state);
$redirectUri = route('dashboard.vikon-updates.callback');
$url = config('vikon.auth_domain') . 'oauth2/authorize'
. '?client_id=' . config('vikon.client_id')
. '&redirect_uri=' . urlencode($redirectUri)
. '&response_type=code'
. '&state=' . $state;
return response()->json(['url' => $url]);
}
}
@@ -5,9 +5,11 @@ use Illuminate\Support\Facades\Route;
Route::prefix('/dashboard/vikon-updates')
->name('dashboard.vikon-updates.')
->middleware(['access-check', 'dashboard.auth', 'throttle:30,1'])
->middleware(['access-check', 'dashboard.auth', 'throttle:30,1', 'vikon.refresh'])
->group(function () {
Route::get('/', [VikonController::class, 'index'])->name('index');
Route::get('/callback', [VikonController::class, 'oauthCallback'])->name('callback');
Route::post('/authorize', [VikonController::class, 'authorize'])->name('authorize');
Route::post('/authenticate', [VikonController::class, 'authenticate'])->name('authenticate');
Route::post('/refresh-token', [VikonController::class, 'refreshToken'])->name('refresh-token');
Route::post('/check-access', [VikonController::class, 'checkAccess'])->name('check-access');