Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const Providers = ({ children, hasRefreshToken }: Props) => {
<QueryProvider>
<MSWProvider>
<AuthProvider hasRefreshToken={hasRefreshToken}>
<NotificationProvider hasRefreshToken={hasRefreshToken}>
<NotificationProvider>
<LazyMotionProvider>
<ToastProvider>
<ModalProvider>{children}</ModalProvider>
Expand Down
146 changes: 0 additions & 146 deletions src/hooks/use-notification/use-notification-connect-sse/index.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/hooks/use-sse/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useSSEConnect } from './use-sse-connect';
export { useSSEEvent } from './use-sse-event';
122 changes: 122 additions & 0 deletions src/hooks/use-sse/use-sse-connect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';

import Cookies from 'js-cookie';

import { API } from '@/api';
import { NotificationItem } from '@/types/service/notification';

export const useSSEConnect = () => {
const [data, setData] = useState<NotificationItem | null>(null);

const eventSourceRef = useRef<EventSource | null>(null);
const retryRefreshRef = useRef(false);
const isMountedRef = useRef(true);

// SSE 연결 진입점
const connect = () => {
const token = Cookies.get('accessToken');
if (!token) {
console.log('[DEBUG] SSE - 토큰 없음');
return;
}

setupSSEConnection(token);
};

// SSE 연결 해제 함수
const disconnect = () => {
if (eventSourceRef.current) {
console.log('[DEBUG] SSE - 연결 정리');
eventSourceRef.current.close();
eventSourceRef.current = null;
}
retryRefreshRef.current = false;
};

// SSE 재연결 시도 함수
const reconnect = async () => {
retryRefreshRef.current = true;
console.log('[DEBUG] SSE - 토큰 갱신 시도');

try {
await API.authService.refresh();
const token = Cookies.get('accessToken');
if (token) {
setupSSEConnection(token);
}
} catch (error) {
console.error('[DEBUG] SSE - 토큰 갱신 실패:', error);
disconnect();
}
};

// SSE 연결 설정 함수
const setupSSEConnection = (token: string) => {
if (!isMountedRef.current) {
console.log('[DEBUG] SSE - 언마운트된 컴포넌트, 연결 중단');
return;
}

// 1. 기존 연결 정리
if (eventSourceRef.current) {
console.log('[DEBUG] SSE - 기존 연결 정리');
eventSourceRef.current.close();
eventSourceRef.current = null;
}

// 2. SSE 연결 시도
console.log('[DEBUG] SSE - 연결 시도');
const es = new EventSource(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/notifications/subscribe?accessToken=${token}`,
);
Comment on lines +69 to +71
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

EventSource API custom headers support authentication best practices

💡 Result:

Does the browser EventSource API support custom headers?

No. The native browser EventSource(url, { withCredentials }) does not let you set arbitrary request headers like Authorization. This is a known/long-standing spec + implementation limitation. [1][2]

Authentication best practices for SSE when you can’t set headers

  1. Prefer cookie-based auth with HttpOnly session cookies (and use withCredentials: true when you need cookies on cross-origin SSE). This is the main thing native EventSource supports beyond the URL. [2][3]
  2. Avoid putting access tokens in the URL query string (/sse?token=...) because URLs get logged/cached and can leak via referrers/history. [4]
  3. If you use cookies, treat SSE like any other credentialed request:
    • Use TLS everywhere.
    • Lock down CORS (specific origins, Access-Control-Allow-Credentials: true only when needed).
    • Mitigate CSRF (e.g., SameSite cookies, CSRF tokens on state-changing endpoints; SSE itself is usually GET/read-only, but your overall session still matters).

If you need Authorization: Bearer ... (custom headers)

Use a fetch-based SSE client (recommended) or a polyfill:

  • Fetch-based SSE client (recommended): @microsoft/fetch-event-source (or equivalents) lets you send headers/body/method because it’s built on fetch(). [3]
  • Polyfill approach: event-source-polyfill/Yaffle’s EventSource polyfill supports custom headers via an init option. [5]

Token lifecycle best practice (important for long-lived streams)

Even with header-capable clients, plan for token expiry/refresh: long-lived connections + short-lived JWTs require a strategy (reconnect with a fresh token, or prefer session cookies). [2]

Sources: WHATWG html issue on headers [1], StackOverflow confirmation of native limitation + cookie suggestion [2], @microsoft/fetch-event-source README (custom headers via fetch) [3], discussion of URL token leakage risk [4], EventSource polyfill custom headers example [5].


🏁 Script executed:

# Check the implementation context
cat -n src/hooks/use-sse/use-sse-connect/index.ts | head -80

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 2780


EventSource 요청에서 accessToken을 URL 쿼리 파라미터로 전달하지 마세요.

토큰이 이미 HttpOnly 쿠키에 저장되어 있으므로, 다시 추출하여 URL에 노출하면 안 됩니다. 쿼리 파라미터로 전달된 토큰은 다음과 같이 유출될 수 있습니다:

  • 서버 액세스 로그에 기록
  • 브라우저 히스토리에 저장
  • Referer 헤더를 통해 제3자에게 유출

해결 방법: EventSource 생성 시 withCredentials: true 옵션을 추가하여 브라우저가 자동으로 쿠키를 전송하도록 변경하고, URL에서 accessToken 파라미터를 제거하세요.

const es = new EventSource(
  `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/notifications/subscribe`,
  { withCredentials: true }
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-sse/use-sse-connect/index.ts` around lines 63 - 65, The
EventSource instantiation in use-sse-connect is leaking the access token via URL
query; remove the accessToken query parameter from the EventSource URL and
create the EventSource with credentials enabled so the HttpOnly cookie is sent
automatically (update the EventSource call in use-sse-connect/index.ts where the
EventSource is constructed to call the subscribe endpoint without the token
query and pass { withCredentials: true } as the second argument).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 백엔드 요청하여 수정진행하겠습니다. 이슈 생성 부탁드립니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@Chiman2937, 네, 이슈를 생성해드리겠습니다.


🏁 Script executed:

#!/bin/bash
# Create a GitHub issue for the SSE token security concern

gh issue create \
  --title "SSE 연결에서 accessToken URL 노출 보안 문제 해결" \
  --body "## 문제 설명

현재 SSE(Server-Sent Events) 연결 설정 시 accessToken을 URL 쿼리 파라미터로 전달하고 있습니다. 이는 다음과 같은 보안 위험을 초래합니다:

- 서버 액세스 로그에 토큰 기록
- 브라우저 히스토리에 토큰 저장
- Referer 헤더를 통한 제3자 유출 가능성

## 해결 방안

백엔드에서 EventSource 연결 시 HttpOnly 쿠키를 통한 인증을 지원하도록 수정이 필요합니다. 프론트엔드에서는 \`withCredentials: true\` 옵션을 사용하여 쿠키가 자동으로 전송되도록 설정할 수 있습니다.

## 관련 정보

- PR: https://github.com/WeGo-Together/WeGo_FrontEnd/pull/391
- 리뷰 코멘트: https://github.com/WeGo-Together/WeGo_FrontEnd/pull/391#discussion_r2840385123
- 요청자: `@Chiman2937`
- 파일: \`src/hooks/use-sse/use-sse-connect/index.ts\`" \
  --assignee "Chiman2937"

Length of output: 57


@Chiman2937, 이슈를 생성했습니다: #392

백엔드 수정 요청 후 진행하시면 됩니다. 감사합니다!


eventSourceRef.current = es;

// 3. SSE 연결 성공 시
es.addEventListener('connect', (event) => {
console.log('[DEBUG] SSE - 연결 확인:', event.data);
retryRefreshRef.current = false;
});

// 4. SSE 이벤트 수신 시
es.addEventListener('notification', (event) => {
try {
const receivedData = JSON.parse(event.data) as NotificationItem;
setData(receivedData);
console.log('[DEBUG] SSE - 수신 성공:', receivedData);
} catch (error) {
console.error('[DEBUG] SSE - 데이터 파싱 실패:', error);
}
});

// 5. SSE 연결 오류 발생 시
es.onerror = async (_error) => {
console.log('[DEBUG] SSE - 연결 오류 발생');
es.close();
if (retryRefreshRef.current) return;
reconnect();
};
};

useEffect(() => {
isMountedRef.current = true;
connect();
return () => {
isMountedRef.current = false;
disconnect();
};
}, []);

// 알림 수신 후 3초 뒤 data가 null로 변경됨
useEffect(() => {
if (!data) return;

const timer = setTimeout(() => {
setData(null);
}, 3000);

return () => clearTimeout(timer);
}, [data]);

return { data };
};
38 changes: 38 additions & 0 deletions src/hooks/use-sse/use-sse-event/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect } from 'react';

import { QueryKey, useQueryClient } from '@tanstack/react-query';

import { groupKeys } from '@/lib/query-key/query-key-group';
import { notificationKeys } from '@/lib/query-key/query-key-notification';
import { userKeys } from '@/lib/query-key/query-key-user';
import { NotificationItem } from '@/types/service/notification';

const SSE_INVALIDATION_MAP: Partial<
Record<NotificationItem['type'], (data: NotificationItem) => QueryKey[]>
> = {
FOLLOW: (data) => [userKeys.me(), userKeys.item(data.user.id)],
GROUP_CREATE: () => [groupKeys.lists()],
GROUP_DELETE: () => [groupKeys.lists()],
GROUP_JOIN: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []),
GROUP_LEAVE: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []),
GROUP_JOIN_APPROVED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []),
GROUP_JOIN_REJECTED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []),
GROUP_JOIN_KICKED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []),
GROUP_JOIN_REQUEST: (data) =>
data.group ? [groupKeys.joinRequests(String(data.group.id), 'PENDING')] : [],
};

export const useSSEEvent = (data: NotificationItem | null) => {
const queryClient = useQueryClient();

useEffect(() => {
if (!data) return;
queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() });
queryClient.invalidateQueries({ queryKey: notificationKeys.list() });

const getQueryKeys = SSE_INVALIDATION_MAP[data.type];
getQueryKeys?.(data).forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey });
});
}, [data]);
};
20 changes: 7 additions & 13 deletions src/providers/provider-notification/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect } from 'react';
import { createContext, useContext } from 'react';

import { useGetNotificationUnreadCount } from '@/hooks/use-notification';
import { useConnectSSE } from '@/hooks/use-notification/use-notification-connect-sse';
import { useSSEConnect, useSSEEvent } from '@/hooks/use-sse';

interface NotificationContextType {
unReadCount: number;
Expand All @@ -18,20 +18,14 @@ export const useNotification = () => {

interface NotificationProviderProps {
children: React.ReactNode;
hasRefreshToken: boolean;
}

export const NotificationProvider = ({ children, hasRefreshToken }: NotificationProviderProps) => {
export const NotificationProvider = ({ children }: NotificationProviderProps) => {
const { data: unReadCount = 0 } = useGetNotificationUnreadCount();
const { receivedNewNotification, connect, disconnect } = useConnectSSE(hasRefreshToken);

useEffect(() => {
connect();
return () => {
disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { data: receivedData } = useSSEConnect();
useSSEEvent(receivedData);

const receivedNewNotification = !!receivedData;

return (
<NotificationContext.Provider value={{ unReadCount, receivedNewNotification }}>
Expand Down
17 changes: 2 additions & 15 deletions src/types/service/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,22 @@ export type NotificationType =
| 'GROUP_JOIN_REJECTED'
| 'GROUP_JOIN_KICKED';

type NotificationTypeWithoutGroup = 'FOLLOW';
type NotificationTypeWithGroup = Exclude<NotificationType, NotificationTypeWithoutGroup>;

interface BaseNotification {
export interface NotificationItem {
id: number;
message: string;
readAt: string | null;
createdAt: string;
type: NotificationType;
user: {
id: number;
nickname: string;
};
}

interface NotificationWithoutGroup extends BaseNotification {
type: NotificationTypeWithoutGroup;
group: null;
}

interface NotificationWithGroup extends BaseNotification {
type: NotificationTypeWithGroup;
group: {
id: number;
title: string;
} | null;
}

export type NotificationItem = NotificationWithoutGroup | NotificationWithGroup;

export interface NotificationList {
notifications: NotificationItem[];
nextCursor: number | null;
Expand Down