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
59 changes: 43 additions & 16 deletions apps/client/src/pages/jobPins/JobPins.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useGetJobPinsArticles } from '@pages/jobPins/apis/queries';
import {
useGetJobPinsArticleDetail,
useGetJobPinsArticles,
} from '@pages/jobPins/apis/queries';
import JobPinsBottomNotice from '@pages/jobPins/components/JobPinsBottomNotice';
import MemoPopup from '@pages/jobPins/components/MemoPopup';
import { useJobPinsBottomNotice } from '@pages/jobPins/hooks/useJobPinsBottomNotice';
import Footer from '@pages/myBookmark/components/footer/Footer';
import { Card } from '@pinback/design-system/ui';
Expand All @@ -11,6 +15,11 @@ const JobPins = () => {

const { data, isPending, fetchNextPage, hasNextPage } =
useGetJobPinsArticles();
const {
mutate: getJobPinDetail,
data: jobPinDetail,
reset: resetJobPinDetail,
} = useGetJobPinsArticleDetail();

const observerRef = useInfiniteScroll({
fetchNextPage,
Expand All @@ -33,6 +42,7 @@ const JobPins = () => {
<p className="head3">관심 직무 핀</p>
{job && <p className="head3 text-main500">{job}</p>}
</div>

<p className="body3-r text-font-gray-3 mt-[0.8rem]">
같은 직무의 사람들이 저장한 아티클을 살펴봐요. 선택한 직무를 기준으로
최신 핀이 업데이트 돼요!
Expand All @@ -48,32 +58,49 @@ const JobPins = () => {
onWheel={handleBottomWheel}
className="scrollbar-hide mt-[2.6rem] flex h-screen flex-wrap content-start gap-[1.6rem] overflow-y-auto scroll-smooth"
>
{articlesToDisplay.map((article) => (
<Card
key={article.articleId}
type="bookmark"
variant="save"
title={article.title}
imageUrl={article.thumbnailUrl}
content={article.memo}
category={article.category.categoryName}
categoryColor={article.category.categoryColor}
nickname={article.ownerName}
onClick={() => window.open(article.url, '_blank')}
/>
))}
{articlesToDisplay.map((article) => {
const displayTitle = article.title?.trim()
? article.title
: '제목 없음';

const displayImageUrl = article.thumbnailUrl || undefined;

return (
<Card
key={article.articleId}
type="bookmark"
variant="save"
title={displayTitle}
imageUrl={displayImageUrl}
content={article.memo}
category={article.category?.categoryName}
categoryColor={article.category?.categoryColor}
nickname={article.ownerName}
onClick={() => getJobPinDetail(article.articleId)}
/>
);
})}

<JobPinsBottomNotice visible={isBottomNoticeVisible} />
<div ref={observerRef} style={{ height: '1px', width: '100%' }} />
</div>
) : (
// TODO: 아티클 없는경우 UI 수정
<p className="body2-m text-font-gray-3 mt-[4rem]">
아직 공유된 아티클이 없어요.
</p>
)}

<Footer />

{/* Memo Popup */}
{jobPinDetail && (
<MemoPopup
userName={jobPinDetail.ownerName}
memo={jobPinDetail.memo}
onClose={resetJobPinDetail}
Copy link
Member

Choose a reason for hiding this comment

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

여기서 이렇게 reset을 사용해주셨군요! 👍

onGoArticle={() => window.open(jobPinDetail.url, '_blank')}
Copy link

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

🏁 Script executed:

# 1. 지정된 파일의 line 101 확인
head -105 apps/client/src/pages/jobPins/JobPins.tsx | tail -10

Repository: Pinback-Team/pinback-client

Length of output: 337


🏁 Script executed:

# 2. _blank 사용 시 noopener/noreferrer 누락 지점 확인
echo "=== All window.open with '_blank' ===" 
rg -nP "window\.open\([^)]*['\"]_blank['\"]\s*\)" --type ts --type tsx
echo ""
echo "=== window.open with security parameters ===" 
rg -nP "window\.open\([^)]*['\"]_blank['\"]\s*,\s*['\"][^'\"]*noopener[^'\"]*['\"]\s*\)" --type ts --type tsx

Repository: Pinback-Team/pinback-client

Length of output: 227


🏁 Script executed:

# 3. 파일 존재 여부 확인
test -f apps/client/src/pages/jobPins/JobPins.tsx && echo "파일 존재함" || echo "파일 없음"

Repository: Pinback-Team/pinback-client

Length of output: 77


🏁 Script executed:

# window.open() 사용 패턴 전체 검색 (파일 타입 필터 제거)
rg "window\.open" --no-type-list -A 1

Repository: Pinback-Team/pinback-client

Length of output: 514


🏁 Script executed:

# 보안 파라미터 없이 '_blank' 사용하는 인스턴스 확인
rg "window\.open\([^)]*_blank[^)]*\)" --no-type-list

Repository: Pinback-Team/pinback-client

Length of output: 514


🏁 Script executed:

# window.open 사용 패턴 전체 검색
rg "window\.open" -A 1

Repository: Pinback-Team/pinback-client

Length of output: 650


🏁 Script executed:

# '_blank' 사용 시 보안 파라미터 여부 확인
rg "window\.open.*_blank"

Repository: Pinback-Team/pinback-client

Length of output: 410


새 탭 오픈 시 보안 파라미터 noopener,noreferrer 추가 필요

window.open(..., '_blank')만 사용하면 opener를 통한 탭 하이재킹 공격에 취약합니다.

권장 수정안
-          onGoArticle={() => window.open(jobPinDetail.url, '_blank')}
+          onGoArticle={() =>
+            window.open(jobPinDetail.url, '_blank', 'noopener,noreferrer')
+          }

동일한 패턴의 취약점이 다른 파일에도 존재합니다:

  • apps/client/src/pages/remind/Remind.tsx
  • apps/client/src/pages/myBookmark/components/myBookmarkContent/MyBookmarkContent.tsx

모든 인스턴스에 보안 파라미터를 추가해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/jobPins/JobPins.tsx` at line 101, The onGoArticle
handler in JobPins.tsx (and the similar handlers in Remind.tsx and
MyBookmarkContent) opens links with window.open(jobPinDetail.url, '_blank')
which is vulnerable to tab‑hijacking; update these handlers to include the
security features by passing the third parameter with "noopener,noreferrer"
(i.e., use window.open(url, '_blank', 'noopener,noreferrer')) or alternatively
set the link target with rel="noopener noreferrer" when rendering an anchor;
locate the onGoArticle usage in JobPins.tsx and the equivalent functions in
Remind.tsx and MyBookmarkContent and apply the same change to all instances.

/>
)}
</div>
);
};
Expand Down
19 changes: 18 additions & 1 deletion apps/client/src/pages/jobPins/apis/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import apiRequest from '@shared/apis/setting/axiosInstance';
import { JobPinsResponse } from '@pages/jobPins/types/api';
import apiRequest from '@shared/apis/setting/axiosInstance';

interface ApiResponse<T> {
code: string;
Expand All @@ -20,3 +20,20 @@ export const getJobPinsArticles = async (

return (data as ApiResponse<JobPinsResponse>).data;
};

export interface JobPinsDetailResponse {
articleId: number;
ownerName: string;
memo: string | null;
url: string;
}

export const getJobPinsArticleDetail = async (
articleId: number
): Promise<JobPinsDetailResponse> => {
const { data } = await apiRequest.get(
`/api/v3/articles/shared/job/${articleId}`
);

return (data as ApiResponse<JobPinsDetailResponse>).data;
};
14 changes: 12 additions & 2 deletions apps/client/src/pages/jobPins/apis/queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { JobPinsResponse } from '@pages/jobPins/types/api';
import { getJobPinsArticles } from './axios';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import {
getJobPinsArticleDetail,
getJobPinsArticles,
JobPinsDetailResponse,
} from './axios';

export const useGetJobPinsArticles = () => {
return useInfiniteQuery<JobPinsResponse>({
Expand All @@ -16,3 +20,9 @@ export const useGetJobPinsArticles = () => {
},
});
};

export const useGetJobPinsArticleDetail = () => {
return useMutation<JobPinsDetailResponse, Error, number>({
mutationFn: (articleId: number) => getJobPinsArticleDetail(articleId),
});
};
Comment on lines +24 to +28
Copy link
Member

@constantly-dev constantly-dev Feb 26, 2026

Choose a reason for hiding this comment

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

지금 막 생각을 해보니까 useMutationuseQuery에 대한 선택을 이유에 따라 더 고민해봐도 좋을 것 같아요!
사실 단순하게 생각하면 아티클 카드를 클릭하는 트리거를 통해 데이터를 가져오니까 useMutation을 쓸 수 있겠지만, useMutationuseQuery처럼 캐싱이 없으니까요!

물론 직무 아티클이 사용자가 바꾸면 변동이 있지만 메타데이터인 만큼 사실 변동이 자주 있는 것은 아닌 것 같아서, 캐시를 못 쓰고 이를 클릭할 때마다 데이터를 새롭게 받아오면 그만큼 로딩이 느려지는 UX 문제가 있을 것 같아요.

따라서 이 경계를 저희 기능/문제에 따라 잘 판단해야 할 것 같아요.

만약 이 로딩이 꽤 느리게 느껴진다면 캐시는 하되 열 때/포커스 때 최신화는 강하게 설정해서 useQuery를 이용할 수 있을 것 같아요.

queryKey에 id도 추가하고, staleTime/refetchOnMount/gcTime 등을 적절하게 조절하면 캐싱을 하면서도 최신 데이터를 잘 유지할 수 있다고 생각합니다! 아니면 enabled를 false로 둬서 처음은 패칭 안하고, 이후 refetch function을 통해 클릭하면 데이터 불러오게 하면서도 캐싱을 할 수 있지 않을까?? 라는 생각도 들어요!

[예시 코드]

const { data, refetch } = useQuery({
  queryKey: ['searchResult'],
  queryFn: fetchSearch,
  enabled: false, // 처음에는 자동으로 실행되지 않음!
});

// 버튼 클릭 시 호출
const handleClick = () => {
  refetch(); // 이때 데이터를 가져오고, 결과는 캐싱
};

이 부분도 다시 한번 생각해보시고 의견 내주시면 좋을 것 같아요~~

51 changes: 51 additions & 0 deletions apps/client/src/pages/jobPins/components/MemoPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Icon } from '@pinback/design-system/icons';
import { Button } from '@pinback/design-system/ui';

interface MemoPopupProps {
userName: string;
memo?: string | null;
onClose: () => void;
onGoArticle: () => void;
}

export default function MemoPopup({
userName,
memo,
onClose,
onGoArticle,
}: MemoPopupProps) {
const hasMemo = memo && memo.trim().length > 0;

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
<div className="w-[31.2rem] rounded-[1.2rem] bg-white px-[3.2rem] py-[2.4rem]">
{/* Header */}
<div className="flex items-center justify-between">
<Icon name="logo" width={72} height={20} />
<button onClick={onClose}>
<Icon name="ic_close" size={20} />
</button>
</div>

{/* Title */}
<div className="caption1-sb text-font-black-1 mt-[1.6rem]">
<span className="text-main500">{userName}</span>님의 메모
</div>

{/* Memo */}
<div className="body3-r text-font-gray-3 mt-[1.2rem] h-[9.6rem] overflow-y-auto whitespace-pre-line">
{hasMemo ? (
memo
) : (
<div className="text-gray-400">작성된 메모가 없어요.</div>
)}
</div>

{/* Button */}
<Button onClick={onGoArticle} className="mt-[2.4rem] w-full">
아티클 보러 가기
</Button>
</div>
</div>
);
}
Loading