Is ggsel Ханти: 1080p на Twitch, игры дешевле и Steam в рублях safe?

Medium risk

The extension sends Twitch browsing activity and a persistent user ID to both the operator's backend and Yandex Metrica on every channel visit.

Each time a user visits a Twitch channel, the extension forwards the channel URL, game title, stream session details, and a stable cross-session UUID to twitch.ggsel.net and to Yandex Metrica (mc.yandex.ru). The persistent user ID is generated on first install and stored in chrome.storage.local, allowing the operator to track the same user across sessions. The extension also polls the operator backend every three minutes and displays push notifications with operator-controlled titles, bodies, and click URLs.

ggselv1.0.17.1Chrome Web Store
45Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

Twitch activity sent to ggsel.net events backend

The extension POSTed an event batch to twitch.ggsel.net/api/v1/events.

Events come from Twitch page visits, popup opens, activity markers, and stream sessions, tagged with a user ID, session ID, extension version, and Twitch context.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You browse Twitch or open the extension popup while the extension is installed.

The content script reads the current Twitch page, channel name, and stream-session state from the page.

The extension did this

The extension creates analytics events and sends them to the ggsel.net events backend.

Those events include persistent user and session identifiers that connect multiple actions to the same browser profile.

02EvidenceNETWORK CAPTURE
Captured request
POSThttps://twitch.ggsel.net/api/v1/events
Observed during dynamic analysis: the JSON event batch included a user ID beginning with ac443594, a session ID beginning with 74ed2e90, extension version 1.0.17.1, activity-marker events, and a popup-open event.
Headers
content-typeapplication/json
03EvidenceFIELD TABLE
Fields attached to the ggsel.net event batch
FieldValueWhy it matters
Persistent user ID
ac443594-8a1f-4d3b-9f4c-2b1d6e7c8a90Lets the backend connect events from the same browser profile across separate browsing sessions.
Browser-session ID
74ed2e90-2a6d-46c1-9f9d-1e8f7c5b4a32Groups activity that happened during the same browser session.
Extension version
1.0.17.1Shows which installed version produced the event.
Twitch page location
https://www.twitch.tv/shroudShows which Twitch page or channel was open when the extension created the event.
Channel and game context
channel_name=shroud, game_title=VALORANTDescribes the stream channel and game associated with the visit or stream session.
Stream duration
duration_sec=1840Records how long a stream session lasted before the tab closed, changed, or became idle.
04EvidenceCODE COMPARE
The code that does this

Source code path that creates Twitch events and posts them to ggsel.net

What it actually does
Endpoint configurationcommon/config.js
export const DEFAULT_API_BASE = 'https://twitch.ggsel.net';
export const REQUEST_TIMEOUT_MS = 7000;
// Canonical source marker for links opened from the Twitch extension.
export const DEFAULT_UTM_SOURCE = 'ggsel_twitch_extension';
// Stable canonical UTM medium for the extension (spec 3.3)
export const DEFAULT_UTM_MEDIUM = 'twitch_app';
export const GA_MEASUREMENT_ID = 'G-NCQMPNWKWS';
export const GA_API_SECRET = 'grdZVJk4ROmZPlW9HA_CpQ';
export const YM_COUNTER_ID = '104649646';

// Phase 1 own analytics — third sink parallel to GA4/Yandex.
// Set to false as kill switch if backend is overloaded or under attack.
export const OWN_ANALYTICS_ENABLED = true;
export const OWN_ANALYTICS_TIMEOUT_MS = 5000;

export function buildEventsEndpoint(base = DEFAULT_API_BASE) {
  return new URL('/api/v1/events', base).toString();
}
Twitch visit and stream-session eventscontent/twitch.content.js
  function trackVisit(reason) {
    try {
      const pageUrl = getSafePageLocation();
      usageTracker?.notifyContextChange(buildUsageContext(), reason || 'visit');
      if (pageUrl === lastVisitLocation) return;
      lastVisitLocation = pageUrl;
      const channelName = getChannelName();
      sendAnalyticsEvent('twitch_visit', {
        visit_reason: reason,
        channel_name: channelName || undefined,
        page_location: pageUrl,
        page_path: location.pathname,
      });
      touchMonthlyActive('twitch_visit', {
        visit_reason: reason,
        channel_name: channelName || undefined,
      });
      maybeStartStreamSession(channelName, reason);
    } catch (error) {
      console.warn('Failed to track visit', error);
    }
  }

  function maybeStartStreamSession(channelName, reason) {
    // Switch / leave channel → end the current session before starting a new one
    if (activeStreamSession && activeStreamSession.channelName !== channelName) {
      endStreamSession('navigate');
    }
    if (streamSessionStartTimer) {
      clearTimeout(streamSessionStartTimer);
      streamSessionStartTimer = null;
    }
    if (!channelName) return;
    if (activeStreamSession && activeStreamSession.channelName === channelName) return;
    streamSessionStartTimer = setTimeout(() => {
      streamSessionStartTimer = null;
      // Re-check the channel hasn't changed during the wait.
      if (getChannelName() !== channelName) return;
      const gameTitle = getGameTitle();
      activeStreamSession = {
        sessionId: generateLocalId(),
        startedAt: Date.now(),
        channelName,
        gameTitle,
      };
      sendAnalyticsEvent('stream_session_start', {
        stream_session_id: activeStreamSession.sessionId,
        channel_name: channelName,
        game_title: gameTitle || undefined,
        visit_reason: reason || 'load',
        page_location: getSafePageLocation(),
        page_path: location.pathname,
      });
    }, STREAM_SESSION_MIN_MS);
  }

  function endStreamSession(endReason) {
    if (!activeStreamSession) return;
    const { sessionId, startedAt, channelName, gameTitle } = activeStreamSession;
    activeStreamSession = null;
    const durationMs = Date.now() - startedAt;
    // Re-read game_title at session end — streamer may have switched games.
    // Fall back to the value captured at start so we always have something.
    const finalGameTitle = getGameTitle() || gameTitle || null;
    sendAnalyticsEvent('stream_session_end', {
      stream_session_id: sessionId,
      channel_name: channelName,
      game_title: finalGameTitle || undefined,
      duration_ms: durationMs,
      duration_sec: Math.round(durationMs / 1000),
      end_reason: endReason || 'unknown',
    });
  }
Event object builder and direct POST pathcommon/analytics.js
export async function buildOwnEvent(eventName, params, state) {
  const userId = (state && (state.userId || state.clientId)) || '';
  const sessionId = await getSessionId();
  return {
    event_id: generateId(),
    name: eventName,
    ts: Date.now(),
    user_id: userId,
    session_id: sessionId,
    client_id: (state && state.ggselClientId) || null,
    properties: pickAllowedProperties(params),
    extension_version: getManifestVersion() || null,
  };
}

// When a batch buffer is configured (by background.js), events are queued
// instead of sent immediately. Direct send remains the fallback path for
// non-service-worker contexts.
let ownBackendBuffer = null;

export function setOwnBackendBuffer(buffer) {
  ownBackendBuffer = buffer && typeof buffer.push === 'function' ? buffer : null;
}

// Spec section 6.3: flush queue immediately when popup opens (user is
// actively engaging — minimize buffering latency for these touchpoints).
const FLUSH_AFTER_EVENTS = new Set([
  'popup_open',
  'extension_install',
  'extension_update',
  'ggsel_button_click',
]);

async function sendToOwnBackend(eventName, params, state) {
  if (!OWN_ANALYTICS_ENABLED) return false;
  if (!OWN_BACKEND_KNOWN_EVENTS.has(eventName)) return false;
  if (!state || (!state.userId && !state.clientId)) return false;

  const event = await buildOwnEvent(eventName, params, state);
  if (!event.user_id) return false;

  if (ownBackendBuffer) {
    try {
      await ownBackendBuffer.push(event);
      if (FLUSH_AFTER_EVENTS.has(eventName)) {
        // fire-and-forget — flush errors stay inside the buffer
        ownBackendBuffer.flush().catch(() => {});
      }
      return true;
    } catch (error) {
      console.warn('Own analytics buffer push failed; falling back to direct send', error);
    }
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), OWN_ANALYTICS_TIMEOUT_MS);
  try {
    const response = await fetch(buildEventsEndpoint(), {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ events: [event] }),
      credentials: 'omit',
      signal: controller.signal,
    });
    return response.ok;
  } catch (error) {
    console.warn('Own analytics send failed', error);
    return false;
  } finally {
    clearTimeout(timer);
  }
}
Service-worker batch POST pathbackground.js
async function sendOwnAnalyticsBatch(events) {
  if (!Array.isArray(events) || events.length === 0) return;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), OWN_ANALYTICS_TIMEOUT_MS);
  try {
    const response = await fetch(buildEventsEndpoint(), {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ events }),
      credentials: 'omit',
      signal: controller.signal,
    });
    if (!response.ok) {
      throw new Error(`analytics batch HTTP ${response.status}`);
    }
  } finally {
    clearTimeout(timer);
  }
}
05EvidenceTHIRD PARTY LIST
Network destinations involved in this claim
  • twitch.ggsel.net

    Receives the extension-owned JSON event batches for Twitch visits, popup opens, activity markers, and stream sessions.

Data recipients

twitch.ggsel.netmc.yandex.ru
Updated 17 September 2026djhlginleaibmpefikimccehaonbgjdp