Harden Telegram connect: one-time code, drop session, decode validation; switch icon to png; widen add-social dialog

This commit is contained in:
Paulo Castellano 2026-06-14 09:18:24 -03:00
parent 2284596f9d
commit d729c34c0e
9 changed files with 58 additions and 37 deletions

View file

@ -15,8 +15,6 @@ class TelegramController extends SocialController
{
protected SocialPlatform $platform = SocialPlatform::Telegram;
private const SESSION_KEY = 'telegram_connect_code';
/**
* Start a connection: issue a signed one-off code the user posts in their
* channel (`/connect <code>`). The code carries the workspace, so the webhook
@ -33,26 +31,23 @@ public function connect(Request $request): JsonResponse
$this->ensureSocialAccountLimit($workspace);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
$request->session()->put(self::SESSION_KEY, $code);
return response()->json([
'code' => $code,
'code' => TelegramConnectCode::issue($workspace->id, $expiresAt),
'bot_username' => config('trypost.platforms.telegram.bot_username'),
'expires_at' => $expiresAt->toIso8601String(),
]);
}
/**
* Poll whether the channel issued in this session has been linked yet.
* Poll whether the channel for the given code has been linked yet.
*/
public function status(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$payload = TelegramConnectCode::decode($request->session()->get(self::SESSION_KEY));
$payload = TelegramConnectCode::decode($request->query('code'));
if ($payload === null) {
return response()->json(['status' => TelegramConnectStatus::Unknown->value]);

View file

@ -12,6 +12,7 @@
use App\Services\Social\TelegramConnectCode;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@ -59,6 +60,11 @@ public function handle(Request $request): Response
return response()->noContent();
}
// Consume the code once so a leaked code can't be replayed to link another chat.
if (! Cache::add("telegram:connect:{$payload['nonce']}", true, now()->addMinutes(15))) {
return response()->noContent();
}
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::Telegram->value,

View file

@ -28,11 +28,11 @@ public static function issue(string $workspaceId, CarbonInterface $expiresAt): s
/**
* Decode and validate a code, returning its payload or null when the code is
* missing, tampered with, or expired.
* missing, tampered with, malformed, or expired.
*
* @return array{workspace_id: string, nonce: string, expires_at: int}|null
*/
public static function decode(?string $code): ?array
public static function decode(mixed $code): ?array
{
if (! is_string($code) || $code === '') {
return null;
@ -44,7 +44,12 @@ public static function decode(?string $code): ?array
return null;
}
if (! is_array($payload) || now()->getTimestamp() > (int) data_get($payload, 'expires_at')) {
if (
! is_array($payload)
|| ! is_string(data_get($payload, 'workspace_id'))
|| ! is_string(data_get($payload, 'nonce'))
|| now()->getTimestamp() > (int) data_get($payload, 'expires_at')
) {
return null;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 240 240" fill="none">
<defs>
<linearGradient id="tg" x1="120" y1="0" x2="120" y2="240" gradientUnits="userSpaceOnUse">
<stop stop-color="#2AABEE" />
<stop offset="1" stop-color="#229ED9" />
</linearGradient>
</defs>
<circle cx="120" cy="120" r="120" fill="url(#tg)" />
<path fill="#fff" d="M54 118.7c35-15.2 58.3-25.3 70-30.2 33.3-13.9 40.2-16.3 44.7-16.4 1 0 3.2.2 4.7 1.4.8.7 1.2 1.6 1.4 2.6.2 1 .4 3.2.2 4.9-1.8 19-9.7 65.1-13.7 86.4-1.7 9-5 12.1-8.2 12.4-7 .6-12.3-4.6-19-9-10.6-7-16.6-11.3-26.9-18.1-11.9-7.8-4.2-12.1 2.6-19.1 1.8-1.8 32.5-29.8 33.1-32.3.1-.3.1-1.5-.6-2.1-.7-.6-1.7-.4-2.5-.2-1.1.2-17.9 11.4-50.6 33.4-4.8 3.3-9.1 4.9-13 4.8-4.3-.1-12.5-2.4-18.6-4.4-7.5-2.4-13.5-3.7-13-7.9.3-2.2 3.3-4.4 9.1-6.6z" />
</svg>

Before

Width:  |  Height:  |  Size: 862 B

View file

@ -101,7 +101,7 @@ const platformTheme: Record<
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.svg',
image: '/images/accounts/telegram.png',
},
};
@ -130,7 +130,7 @@ const connectPlatform = (platformValue: string) => {
<template>
<div>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-3xl">
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{{
$t('accounts.add_social_title')
@ -140,7 +140,7 @@ const connectPlatform = (platformValue: string) => {
</DialogDescription>
</DialogHeader>
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="platform in platforms"

View file

@ -57,7 +57,9 @@ const poll = async () => {
if (phase.value !== 'ready') return;
try {
const response = await httpStatus.get(telegramStatus.url());
const response = await httpStatus.get(
telegramStatus.url({ query: { code: code.value } }),
);
if (response?.status === 'connected') {
phase.value = 'connected';
@ -123,7 +125,7 @@ onUnmounted(stopPolling);
<DialogHeader>
<div class="flex items-center gap-3">
<img
src="/images/accounts/telegram.svg"
src="/images/accounts/telegram.png"
alt="Telegram"
class="size-10"
/>

View file

@ -11,7 +11,7 @@ const PLATFORM_LOGOS: Record<string, string> = {
bluesky: '/images/accounts/bluesky.png',
pinterest: '/images/accounts/pinterest.png',
mastodon: '/images/accounts/mastodon.png',
telegram: '/images/accounts/telegram.svg',
telegram: '/images/accounts/telegram.png',
};
const PLATFORM_LABELS: Record<string, string> = {
@ -32,7 +32,11 @@ const PLATFORM_LABELS: Record<string, string> = {
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
instagram: ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': [
'instagram_feed',
'instagram_reel',
'instagram_story',
],
linkedin: ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
facebook: ['facebook_post', 'facebook_reel', 'facebook_story'],

View file

@ -43,12 +43,11 @@ function telegramUpdate(string $code, array $chat = []): array
];
}
it('issues a signed connect code and stores it in the session', function () {
it('issues a signed connect code carrying the workspace', function () {
$response = $this->actingAs($this->user)
->postJson(route('app.social.telegram.connect'))
->assertOk()
->assertJsonStructure(['code', 'bot_username', 'expires_at'])
->assertSessionHas('telegram_connect_code');
->assertJsonStructure(['code', 'bot_username', 'expires_at']);
expect($response->json('bot_username'))->toBe('TryPostBot');
expect(data_get(TelegramConnectCode::decode($response->json('code')), 'workspace_id'))
@ -127,6 +126,23 @@ function telegramUpdate(string $code, array $chat = []): array
)->toBe(1);
});
it('consumes the code once so it cannot be replayed for another chat', function () {
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1001111111111]))
->assertNoContent();
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1002222222222]))
->assertNoContent();
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001111111111')->exists()
)->toBeTrue();
});
it('rejects the webhook without the secret token', function () {
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
@ -148,13 +164,12 @@ function telegramUpdate(string $code, array $chat = []): array
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0);
});
it('reports the session connection status while pending and once connected', function () {
it('reports the connection status for a code while pending and once connected', function () {
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$nonce = data_get(TelegramConnectCode::decode($code), 'nonce');
$this->actingAs($this->user)
->withSession(['telegram_connect_code' => $code])
->getJson(route('app.social.telegram.status'))
->getJson(route('app.social.telegram.status', ['code' => $code]))
->assertOk()
->assertJson(['status' => 'pending']);
@ -164,17 +179,21 @@ function telegramUpdate(string $code, array $chat = []): array
]);
$this->actingAs($this->user)
->withSession(['telegram_connect_code' => $code])
->getJson(route('app.social.telegram.status'))
->getJson(route('app.social.telegram.status', ['code' => $code]))
->assertOk()
->assertJson(['status' => 'connected']);
});
it('reports unknown status without a session code', function () {
it('reports unknown status without a valid code', function () {
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status'))
->assertOk()
->assertJson(['status' => 'unknown']);
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status', ['code' => 'tampered']))
->assertOk()
->assertJson(['status' => 'unknown']);
});
it('verifies a connected telegram account via getChat', function () {