Merge PR #502: ужесточить resolveSupportContact

Схема support_url валидируется (http/https/tg, чужое → null), legacy
SUPPORT_USERNAME проверяется регуляркой (URL-образное не клеится в
t.me/https://…), null → кнопка контакта не рендерится вместо дефолтного
@support. Ветки ticketsDisabled/both переведены на общий резолв.
Спасибо @AirP0WeR.
This commit is contained in:
Fringg
2026-07-24 14:21:59 +03:00
3 changed files with 103 additions and 59 deletions

View File

@@ -7,6 +7,10 @@ import { resolveSupportContact } from './supportContact';
* SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из
* `https://help.example.com` получалось `https://t.me/https://help.example.com`.
* Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего.
*
* resolveSupportContact дополнительно защищается от битого конфига: чужие схемы
* (`javascript:` и т.п.) и URL-образный legacy-username не уводят в опенер, а
* возвращают null — кнопку в таком случае не рендерим.
*/
const config = (overrides: Partial<SupportConfig>): SupportConfig => ({
@@ -26,7 +30,7 @@ describe('resolveSupportContact', () => {
);
expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' });
expect(target.url).not.toContain('t.me');
expect(target?.url).not.toContain('t.me');
});
it('телеграм-контакт открывается через telegram-ссылку', () => {
@@ -41,6 +45,24 @@ describe('resolveSupportContact', () => {
expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' });
});
it('tg:// deep link — валидная схема', () => {
const target = resolveSupportContact(
config({ support_url: 'tg://resolve?domain=help', contact_is_telegram: true }),
);
expect(target).toEqual({ kind: 'telegram', url: 'tg://resolve?domain=help' });
});
describe('чужие схемы в support_url отсекаются', () => {
it.each([
'javascript:alert(1)',
'data:text/html,x',
'not a url',
])('%s -> null', (support_url) => {
expect(resolveSupportContact(config({ support_url }))).toBeNull();
});
});
describe('старый бэк без support_url — прежнее поведение', () => {
it.each([
['@help', 'https://t.me/help'],
@@ -51,11 +73,17 @@ describe('resolveSupportContact', () => {
expect(target).toEqual({ kind: 'telegram', url: expected });
});
it('контакт вообще не задан — дефолтный @support', () => {
expect(resolveSupportContact(config({}))).toEqual({
kind: 'telegram',
url: 'https://t.me/support',
});
it.each([
'https://help.example.com',
'help.example.com',
't.me/help',
])('URL-образный legacy username (%s) не клеится в t.me — null', (support_username) => {
expect(resolveSupportContact(config({ support_username }))).toBeNull();
});
it('контакт вообще не задан — кнопки нет (null, без дефолта @support)', () => {
expect(resolveSupportContact(config({}))).toBeNull();
expect(resolveSupportContact(config({ support_username: ' ' }))).toBeNull();
});
});
@@ -64,6 +92,6 @@ describe('resolveSupportContact', () => {
// поведение, а не в переход по внешней ссылке.
const target = resolveSupportContact(config({ support_url: 'https://t.me/help' }));
expect(target.kind).toBe('telegram');
expect(target?.kind).toBe('telegram');
});
});

View File

@@ -10,6 +10,19 @@ export type SupportContactTarget = {
url: string;
};
// Открываем только то, что реально ведёт в браузер/телеграм. support_url приходит
// из админского SUPPORT_USERNAME — чужую схему (`javascript:`, `data:` и т.п.)
// нельзя пускать в опенер как есть.
const ALLOWED_SCHEMES = ['http:', 'https:', 'tg:'];
function isAllowedScheme(url: string): boolean {
try {
return ALLOWED_SCHEMES.includes(new URL(url).protocol);
} catch {
return false;
}
}
/**
* Резолвит контакт поддержки из конфига.
*
@@ -20,17 +33,31 @@ export type SupportContactTarget = {
*
* Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение,
* иначе новый фронт сломался бы на неподнятом бэкенде.
*
* Возвращает `null`, когда открывать нечего (пустой/битый конфиг, чужая схема,
* URL-образный legacy username) — вызывающий в этом случае не рендерит кнопку,
* а не уводит пользователя по мусорной ссылке.
*/
export function resolveSupportContact(config: SupportConfig): SupportContactTarget {
if (config.support_url) {
export function resolveSupportContact(config: SupportConfig): SupportContactTarget | null {
const serverUrl = config.support_url?.trim();
if (serverUrl) {
if (!isAllowedScheme(serverUrl)) return null;
// contact_is_telegram отсутствует только у старого бэка, а он не шлёт и
// support_url — так что явная проверка на false, а не на truthy.
const kind = config.contact_is_telegram === false ? 'external' : 'telegram';
return { kind, url: config.support_url };
return { kind, url: serverUrl };
}
const raw = config.support_username || '@support';
const raw = config.support_username?.trim();
if (!raw) return null;
const username = raw.startsWith('@') ? raw.slice(1) : raw;
// Legacy-путь: голый URL здесь дал бы `t.me/https://…`, поэтому клеим t.me
// только из настоящего юзернейма — всё URL-образное отдаём резолвить бэку.
if (!/^[A-Za-z0-9_]{3,}$/.test(username)) return null;
return { kind: 'telegram', url: `https://t.me/${username}` };
}