Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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';
111 changes: 111 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,111 @@
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);

// 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) => {
// 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();
reconnect();
};
};

useEffect(() => {
connect();
return () => 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