Skip to content
Open
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
93 changes: 36 additions & 57 deletions src/apis/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import axios from 'axios';
import * as Sentry from '@sentry/react';
import type { SeverityLevel } from '@sentry/react';
import {
getAccessToken,
removeAccessToken,
Expand All @@ -14,38 +13,17 @@ import {
isSupportedLang,
} from '../util/languageRouting';
import { analyticsManager } from '../util/analytics';
import {
buildSentryApiErrorMetadata,
createSentryApiError,
markSentryCaptured,
shouldSkipApiError,
} from '../util/sentry';

// Get current mode (DEV, PROD or TEST)
const currentMode = import.meta.env.MODE;
const requestTimeoutMs = 5000;

type SentryCapturedError = {
__sentry_captured__?: boolean;
};

export function normalizeEndpoint(url?: string) {
if (!url) {
return 'unknown';
}

return url
.replace(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi,
':uuid',
)
.replace(/[0-9]+/g, ':id');
}

function resolveApiErrorLevel(status: number | undefined) {
// 400/409/422는 사용자 입력·요청 상태 충돌 성격이 커서 warning으로 분리
if (status === 400 || status === 409 || status === 422) {
return 'warning';
}

// 그 외(주로 5xx/네트워크 실패)는 운영 대응이 필요한 장애 신호로 처리
return 'error';
}

// Axios instance
export const axiosInstance = axios.create({
baseURL:
Expand Down Expand Up @@ -75,44 +53,45 @@ function captureClientApiError(error: unknown) {
return;
}

const { response, config, code } = error;
const status = response?.status;
const normalizedUrl = normalizeEndpoint(config?.url);
const requestMethod = config?.method?.toUpperCase() ?? 'UNKNOWN';

// 401은 토큰 재발급 후 원요청 재시도로 자동 복구되는 정상 인증 흐름이므로 수집에서 제외
// 정상/리다이렉트 응답(<400)도 제외하고, 그 외 4xx/5xx/네트워크 실패/타임아웃은 수집
if (status === 401 || (status !== undefined && status < 400)) {
if (shouldSkipApiError(error)) {
return;
}

const level: SeverityLevel = resolveApiErrorLevel(status);
const metadata = buildSentryApiErrorMetadata(error, window.location.pathname);
const sentryError = createSentryApiError(error, metadata);

Sentry.captureException(error, {
level,
tags: {
Sentry.withScope((scope) => {
scope.setLevel(metadata.level);
scope.setTags({
errorType: 'api-error',
httpStatus: status ? String(status) : 'network-error',
endpoint: `${requestMethod} ${normalizedUrl}`,
},
extra: {
pathname: window.location.pathname,
httpStatus: metadata.statusLabel,
endpoint: `${metadata.method} ${metadata.endpoint}`,
feature: metadata.feature,
});
scope.setContext('request', {
pathname: metadata.pathname,
search: window.location.search,
url: config?.url,
method: config?.method,
params: config?.params,
timeout: config?.timeout ?? requestTimeoutMs,
errorCode: code,
},
fingerprint: [
url: error.config?.url,
method: error.config?.method,
params: error.config?.params,
timeout: error.config?.timeout ?? requestTimeoutMs,
errorCode: error.code,
});
scope.setContext('response', {
status: metadata.status,
data: error.response?.data,
});
Comment on lines +71 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Sentry 컨텍스트에 원본 요청/쿼리 데이터를 가공 없이 전송하고 있어 개인정보 유출 위험이 있습니다.

두 위치 모두 사용자 입력이 포함될 수 있는 원본 데이터(쿼리스트링, 요청 파라미터, 응답 바디)를 그대로 Sentry(제3자 서비스)로 전송합니다. 여기에는 이메일, 초대 코드, OAuth code/state, 서버 에러 메시지에 담긴 사용자 식별정보 등 민감정보가 포함될 수 있습니다. 두 사이트는 동일한 근본 원인(원본 데이터를 그대로 전달)을 공유하므로 공통 마스킹/allowlist 유틸을 만들어 적용하는 것을 권장합니다.

  • src/apis/axiosInstance.ts#L71-L83: setContext('request', ...)paramssetContext('response', { data: error.response?.data })data를 민감 필드 마스킹 또는 allowlist 기반 축약 후 전송하도록 수정.
  • src/components/ErrorBoundary/ErrorBoundary.tsx#L49-L53: setContext('render', ...)search: window.location.search를 알려진 민감 쿼리 파라미터(토큰, code, state 등)를 제거/마스킹한 값으로 대체.
📍 Affects 2 files
  • src/apis/axiosInstance.ts#L71-L83 (this comment)
  • src/components/ErrorBoundary/ErrorBoundary.tsx#L49-L53
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/apis/axiosInstance.ts` around lines 71 - 83, Prevent raw user-controlled
data from reaching Sentry by introducing a shared masking or allowlist utility.
In src/apis/axiosInstance.ts#L71-L83, apply it to request params and response
data before setContext; in
src/components/ErrorBoundary/ErrorBoundary.tsx#L49-L53, sanitize
window.location.search by masking or removing sensitive parameters such as
token, code, and state before setting the render context.

scope.setFingerprint([
'api-error',
String(status ?? 'network-error'),
requestMethod,
normalizedUrl,
],
metadata.statusLabel,
metadata.method,
metadata.endpoint,
]);

Sentry.captureException(sentryError);
});

(error as SentryCapturedError).__sentry_captured__ = true;
markSentryCaptured(error);
}

// Response interceptor
Expand Down
9 changes: 2 additions & 7 deletions src/apis/primitives.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios from 'axios';
import { AxiosResponse } from 'axios';
import axiosInstance from './axiosInstance';
import { isSentryCaptured } from '../util/sentry';

// HTTP request methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
Expand All @@ -19,10 +20,6 @@ export class APIError extends Error {
}
}

type SentryCapturedError = {
__sentry_captured__?: boolean;
};

// Low-level http request function
export async function request<T>(
method: HttpMethod,
Expand Down Expand Up @@ -59,9 +56,7 @@ export async function request<T>(
error.response?.status || 500,
responseData,
);
apiError.__sentry_captured__ = (
error as SentryCapturedError
).__sentry_captured__;
apiError.__sentry_captured__ = isSentryCaptured(error);
throw apiError;
}

Expand Down
27 changes: 17 additions & 10 deletions src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import ErrorPage from './ErrorPage';
import {
isSentryCaptured,
resolveFeatureFromPathname,
} from '../../util/sentry';

interface ErrorBoundaryProps {
children: ReactNode;
Expand All @@ -15,10 +19,6 @@ interface ErrorBoundaryState {
const defaultError = new Error('알 수 없는 오류');
const defaultStack = '스택 정보 없음';

type SentryCapturedError = {
__sentry_captured__?: boolean;
};

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
Expand All @@ -37,16 +37,23 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송
if (!(error as SentryCapturedError).__sentry_captured__) {
Sentry.captureException(error, {
tags: {
if (!isSentryCaptured(error)) {
const feature = resolveFeatureFromPathname(window.location.pathname);

Sentry.withScope((scope) => {
scope.setLevel('fatal');
scope.setTags({
errorType: 'render-error',
},
extra: {
feature,
});
scope.setContext('render', {
pathname: window.location.pathname,
search: window.location.search,
componentStack: errorInfo.componentStack,
},
});
scope.setFingerprint(['render-error', feature]);

Sentry.captureException(error);
});
}

Expand Down
95 changes: 95 additions & 0 deletions src/util/sentry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AxiosError, AxiosHeaders } from 'axios';
import {
buildSentryApiErrorMetadata,
createSentryApiError,
normalizeEndpoint,
resolveApiErrorLevel,
resolveFeatureFromPathname,
shouldSkipApiError,
} from './sentry';

describe('sentry 유틸', () => {
it('같은 API 장애가 하나의 알림 이슈로 묶이도록 가변 경로 값을 제거한다', () => {
expect(normalizeEndpoint('/api/tables/123/votes/456?tab=1')).toBe(
'/api/tables/:id/votes/:id',
);
expect(
normalizeEndpoint(
'https://example.com/api/tables/9e770d72-7a5d-4fd7-8c4e-7cf6115a1f6b',
),
).toBe('/api/tables/:uuid');
});

it('알림만 보고 사용자에게 영향받은 기능을 판단할 수 있도록 pathname을 기능 단위로 분류한다', () => {
expect(resolveFeatureFromPathname('/ko/home')).toBe('landing');
expect(resolveFeatureFromPathname('/en/table/customize/1')).toBe('timer');
expect(resolveFeatureFromPathname('/table/customize/1/end')).toBe(
'debate-end',
);
expect(resolveFeatureFromPathname('/table/customize/1/end/vote/2')).toBe(
'vote',
);
expect(resolveFeatureFromPathname('/live/1')).toBe('live-share');
expect(resolveFeatureFromPathname('/oauth')).toBe('auth');
});

it('타이머, 투표, 실시간 공유의 5xx는 핵심 흐름 장애로 보고 즉시 대응 대상으로 분류한다', () => {
expect(resolveApiErrorLevel(500, 'timer')).toBe('fatal');
expect(resolveApiErrorLevel(500, 'vote')).toBe('fatal');
expect(resolveApiErrorLevel(500, 'live-share')).toBe('fatal');
expect(resolveApiErrorLevel(500, 'landing')).toBe('error');
});

it('사용자 입력이나 상태 충돌처럼 예상 가능한 에러는 즉시 대응 알림보다 낮은 우선순위로 분류한다', () => {
expect(resolveApiErrorLevel(400, 'timer')).toBe('warning');
expect(resolveApiErrorLevel(404, 'vote')).toBe('warning');
expect(resolveApiErrorLevel(409, 'live-share')).toBe('warning');
expect(resolveApiErrorLevel(422, 'table-composition')).toBe('warning');
});

it('토큰 재발급으로 복구될 수 있는 401은 즉시 대응 알림에서 제외한다', () => {
const unauthorizedError = new AxiosError(
'unauthorized',
undefined,
undefined,
undefined,
{
status: 401,
statusText: 'Unauthorized',
headers: {},
config: { headers: new AxiosHeaders() },
data: null,
},
);
expect(shouldSkipApiError(unauthorizedError)).toBe(true);
});

it('사용자 오프라인 상태의 네트워크 에러는 운영자가 바로 대응하기 어려워 수집하지 않는다', () => {
const offlineError = new AxiosError('Network Error', 'ERR_NETWORK');

vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);

expect(shouldSkipApiError(offlineError)).toBe(true);
});

it('온라인 상태에서 발생한 네트워크 에러는 서버 연결 실패 가능성이 있어 수집한다', () => {
const onlineNetworkError = new AxiosError('Network Error', 'ERR_NETWORK');

vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);

expect(shouldSkipApiError(onlineNetworkError)).toBe(false);
});

it('알림 제목만 보고 실패한 요청을 파악할 수 있도록 상태 코드, method, endpoint를 담는다', () => {
const error = new AxiosError('Request failed', undefined, {
method: 'post',
url: '/api/live/123',
headers: new AxiosHeaders(),
});
const metadata = buildSentryApiErrorMetadata(error, '/ko/live/123');

const sentryError = createSentryApiError(error, metadata);

expect(sentryError.name).toBe('[network-error] POST /api/live/:id');
});
});
Loading
Loading