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
36 changes: 36 additions & 0 deletions .github/workflows/cleanup-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Cleanup-Preview

on:
pull_request:
types: [closed]
branches: ['develop']

jobs:
cleanup:
runs-on: ubuntu-latest
environment: PREVIEW_ENV
permissions:
pull-requests: write

steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_PREVIEW_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_PREVIEW_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2

- name: Delete Preview from S3
run: |
aws s3 rm s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }}/ --recursive
Comment on lines +23 to +25
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

S3 삭제 후 CloudFront 캐시 무효화가 누락되었습니다.

deploy-preview.yml에서는 CloudFront 무효화를 수행하지만, 정리 워크플로우에서는 S3 객체만 삭제합니다. 캐시된 콘텐츠가 CloudFront 엣지에 남아 있어 삭제된 프리뷰가 TTL 만료 전까지 계속 제공될 수 있습니다.

🛠️ CloudFront 무효화 단계 추가
       - name: Delete Preview from S3
         run: |
           aws s3 rm s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }}/ --recursive
+
+      - name: Invalidate CloudFront Cache
+        run: |
+          aws cloudfront create-invalidation \
+            --distribution-id ${{ secrets.AWS_PREVIEW_CLOUDFRONT_ID }} \
+            --paths "/pr-${{ github.event.pull_request.number }}/*"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Delete Preview from S3
run: |
aws s3 rm s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }}/ --recursive
- name: Delete Preview from S3
run: |
aws s3 rm s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }}/ --recursive
- name: Invalidate CloudFront Cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.AWS_PREVIEW_CLOUDFRONT_ID }} \
--paths "/pr-${{ github.event.pull_request.number }}/*"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/cleanup-preview.yml around lines 23 - 25, The cleanup
workflow's "Delete Preview from S3" step only removes objects from S3 but
doesn't invalidate CloudFront, so cached assets can still be served; update that
step to run an AWS CloudFront invalidation after the aws s3 rm command by
calling the AWS CLI (aws cloudfront create-invalidation) using the distribution
ID from a secret (e.g., AWS_CLOUDFRONT_DISTRIBUTION_ID) and invalidate the
preview path /pr-<pull_request_number>/* (use github.event.pull_request.number
to build the path), and ensure the step handles the operation result (fail the
job on invalidation error or wait for completion) so the preview is fully
removed from the CDN.


- name: Comment Cleanup Notice
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '🧹 Preview 배포가 정리되었습니다.'
});
119 changes: 119 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Deploy-Preview

on:
pull_request:
types: [opened, synchronize, reopened]
branches: ['develop']

concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
deploy-preview:
runs-on: ubuntu-latest
environment: PREVIEW_ENV
permissions:
pull-requests: write
contents: read

steps:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: 20

- name: Checkout Code
uses: actions/checkout@v4

- name: Setup .env
run: |
echo "${{ vars.ENV }}" > .env
echo "${{ secrets.ENV }}" >> .env
echo "VITE_BASE_PATH=/pr-${{ github.event.pull_request.number }}" >> .env

- name: Install Dependencies
run: npm ci

- name: Build Preview
run: npm run build

- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_PREVIEW_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_PREVIEW_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2

- name: Deploy to S3 Preview Bucket
run: |
aws s3 sync ./dist s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }}/ --delete

- name: Deploy OAuth Redirect Handler
run: |
cat > /tmp/oauth-handler.html << 'OAUTH_EOF'
<!DOCTYPE html>
<html>
<head><title>Redirecting...</title></head>
<body>
<script>
var params = new URLSearchParams(window.location.search);
var state = params.get('state');
if (state) {
params.delete('state');
window.location.replace(state + '/oauth?' + params.toString());
}
</script>
</body>
</html>
OAUTH_EOF
Comment on lines +54 to +69
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

oauth-handler.html에 오픈 리다이렉트 취약점이 있습니다.

state 파라미터를 검증 없이 리다이렉트 대상으로 사용하고 있습니다. 공격자가 Google OAuth 콜백에 악의적인 state 값(예: https://evil.com)을 삽입하면, 인증 코드가 공격자 서버로 유출될 수 있습니다.

또한 state가 없을 경우 사용자에게 "Redirecting..." 텍스트만 표시되고 아무 동작도 하지 않습니다.

🔒 state 파라미터 검증 추가
          <script>
            var params = new URLSearchParams(window.location.search);
            var state = params.get('state');
-           if (state) {
+           if (state && /^\/pr-\d+$/.test(state)) {
              params.delete('state');
              window.location.replace(state + '/oauth?' + params.toString());
+           } else {
+             window.location.replace('/oauth?' + params.toString());
            }
          </script>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-preview.yml around lines 54 - 69, The
oauth-handler.html currently reads the URLSearchParams 'state' and calls
window.location.replace(state + '/oauth?' + params.toString()) without
validation, enabling open-redirect; update the JS in oauth-handler.html to
validate the extracted state value against an explicit whitelist or ensure it is
a same-origin path (reject absolute URLs with other origins), only perform
window.location.replace when validation passes, and otherwise render a safe
error/notice or a fixed internal redirect; keep the existing
params.delete('state') behavior but do not redirect if validation fails.

aws s3 cp /tmp/oauth-handler.html s3://${{ secrets.AWS_PREVIEW_BUCKET_NAME }}/oauth --content-type "text/html"

- name: Invalidate CloudFront Cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.AWS_PREVIEW_CLOUDFRONT_ID }} \
--paths "/pr-${{ github.event.pull_request.number }}/*" "/oauth"

- name: Comment Preview URL on PR
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const url = `https://preview.debate-timer.com/pr-${prNumber}/`;

const body = `## 🚀 Preview 배포 완료!

| 환경 | URL |
|-----|-----|
| Preview | [열기](${url}) |
| API | Dev 환경 |

> PR이 닫히면 자동으로 정리됩니다.`;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});

const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Preview 배포 완료')
);

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}
4 changes: 3 additions & 1 deletion src/constants/debate_template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { DebateTemplate } from '../type/type';
function createTableShareUrl(encodeData: string): string {
const baseUrl = import.meta.env.VITE_SHARE_BASE_URL || window.location.origin;
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
return `${normalizedBaseUrl}/share?data=${encodeData}`;
const basePath = import.meta.env.VITE_BASE_PATH;
const pathPrefix = basePath && basePath !== '/' ? basePath : '';
Comment on lines +19 to +20

Choose a reason for hiding this comment

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

medium

VITE_BASE_PATH를 기반으로 pathPrefix를 생성하는 로직이 여러 파일(debate_template.ts, DebateVotePage.tsx, arrayEncoding.ts)에서 동일하게 반복되고 있습니다. 이를 공통 유틸리티 함수로 분리하여 관리하는 것을 권장합니다.

return `${normalizedBaseUrl}${pathPrefix}/share?data=${encodeData}`;
}
interface DebateTemplateList {
ONE: DebateTemplate[];
Expand Down
2 changes: 1 addition & 1 deletion src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ i18n

// 번역 파일을 불러올 위치
backend: {
loadPath: '/locales/{{lng}}/translation.json',
loadPath: `${import.meta.env.VITE_BASE_PATH || ''}/locales/{{lng}}/translation.json`,
},

// React와 함께 사용할 때의 옵션
Expand Down
4 changes: 3 additions & 1 deletion src/page/DebateVotePage/DebateVotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
const isArgsValid = isPollIdValid && isTableIdValid;

const voteUrl = useMemo(() => {
return `${baseUrl}/vote/${pollId}`;
const basePath = import.meta.env.VITE_BASE_PATH;
const pathPrefix = basePath && basePath !== '/' ? basePath : '';
return `${baseUrl}${pathPrefix}/vote/${pollId}`;
Comment on lines +36 to +38

Choose a reason for hiding this comment

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

high

baseUrlundefined일 경우(특히 개발 환경에서), voteUrl"undefined/vote/..."와 같은 잘못된 문자열로 생성됩니다. 또한 baseUrl에 트레일링 슬래시가 포함되어 있을 경우 경로가 중복될 수 있으므로, 다른 파일들(debate_template.ts, arrayEncoding.ts)과 마찬가지로 window.location.origin을 폴백으로 사용하고 슬래시를 정규화하는 로직이 필요합니다.

    const resolvedBaseUrl = (baseUrl || window.location.origin).replace(/\/+$/, '');
    const basePath = import.meta.env.VITE_BASE_PATH;
    const pathPrefix = basePath && basePath !== '/' ? basePath : '';
    return `${resolvedBaseUrl}${pathPrefix}/vote/${pollId}`;

}, [baseUrl, pollId]);

const handleGoToResult = () => {
Expand Down Expand Up @@ -86,7 +88,7 @@
return (
<DefaultLayout>
<DefaultLayout.ContentContainer>
<ErrorIndicator onClickRetry={() => navigate(buildLangPath('/', lang))}>

Check warning on line 91 in src/page/DebateVotePage/DebateVotePage.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `·onClickRetry={()·=>·navigate(buildLangPath('/',·lang))}` with `⏎············onClickRetry={()·=>·navigate(buildLangPath('/',·lang))}⏎··········`
{t('유효하지 않은 투표 링크입니다.')}
</ErrorIndicator>
</DefaultLayout.ContentContainer>
Expand Down
45 changes: 25 additions & 20 deletions src/routes/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,28 +102,33 @@ const protectedAppRoutes = appRoutes.map((route) => ({
),
}));

const router = createBrowserRouter([
const router = createBrowserRouter(
[
{
element: (
<>
<ErrorBoundaryWrapper />
<BackActionHandler />
</>
),
children: [
{
path: '/',
element: <LanguageWrapper />,
children: protectedAppRoutes, // 기본 언어(ko) 라우트
},
{
path: ':lang', // 다른 언어 라우트
element: <LanguageWrapper />,
children: protectedAppRoutes,
},
],
},
],
{
element: (
<>
<ErrorBoundaryWrapper />
<BackActionHandler />
</>
),
children: [
{
path: '/',
element: <LanguageWrapper />,
children: protectedAppRoutes, // 기본 언어(ko) 라우트
},
{
path: ':lang', // 다른 언어 라우트
element: <LanguageWrapper />,
children: protectedAppRoutes,
},
],
basename: import.meta.env.VITE_BASE_PATH || '/',
},
]);
);

// 라우트 변경 시 Google Analytics 이벤트 전송
router.subscribe(({ location }) => {
Expand Down
4 changes: 3 additions & 1 deletion src/util/arrayEncoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export function createTableShareUrl(
const resolvedBaseUrl =
baseUrl && baseUrl.trim() !== '' ? baseUrl : window.location.origin;
const normalizedBaseUrl = resolvedBaseUrl.replace(/\/+$/, '');
return `${normalizedBaseUrl}/share?data=${encoded}`;
const basePath = import.meta.env.VITE_BASE_PATH;
const pathPrefix = basePath && basePath !== '/' ? basePath : '';
return `${normalizedBaseUrl}${pathPrefix}/share?data=${encoded}`;
}

export function extractTableShareUrl(url: string): DebateTableData | null {
Expand Down
8 changes: 7 additions & 1 deletion src/util/googleAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ export const oAuthLogin = () => {
throw new Error('OAuth 정보가 없습니다.');
}

const params = {
const params: Record<string, string> = {
client_id: import.meta.env.VITE_GOOGLE_O_AUTH_CLIENT_ID,
redirect_uri: import.meta.env.VITE_GOOGLE_O_AUTH_REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email',
};

const basePath = import.meta.env.VITE_BASE_PATH;
if (basePath && basePath !== '/') {
params.state = basePath;
}
Comment on lines +17 to +20
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

OAuth state 파라미터를 라우팅 목적으로 사용하면 보안 위험이 있습니다.

OAuth state 파라미터는 일반적으로 CSRF 방지를 위해 사용됩니다. 여기서 basePath를 전달하는 용도로 사용하고 있는데, deploy-preview.ymloauth-handler.htmlstate 값을 기반으로 무조건 리다이렉트하므로 오픈 리다이렉트 취약점이 발생합니다.

공격자가 https://preview.debate-timer.com/oauth?code=AUTH_CODE&state=https://evil.com 같은 URL을 구성하면, oauth-handler.htmlhttps://evil.com/oauth?code=AUTH_CODE로 리다이렉트하여 인증 코드가 유출될 수 있습니다.

oauth-handler.html에서 state 값이 허용된 경로 패턴(예: /pr- 접두사)인지 검증해야 합니다.

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

In `@src/util/googleAuth.ts` around lines 17 - 20, The code currently sets
params.state = basePath (basePath, params.state) which enables open-redirects
because oauth-handler.html blindly redirects using state; update
oauth-handler.html to treat state only as a safe local route by validating the
received state against a whitelist/pattern (e.g., ensure it starts with "/pr-"
or matches allowed path regex) and reject or fall back to a safe default if it
doesn't match, and keep using state for CSRF tokens separately (do not rely on
it for arbitrary redirect destinations).


const queryString = new URLSearchParams(params).toString();
const googleOAuthUrl = `${import.meta.env.VITE_GOOGLE_O_AUTH_REQUEST_URL}?${queryString}`;

Expand Down
1 change: 1 addition & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

interface ImportMetaEnv {
readonly VITE_MOCK_API: string;
readonly VITE_BASE_PATH: string;
}

interface ImportMeta {
Expand Down
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const viteConfig = defineViteConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');

return {
base: env.VITE_BASE_PATH || '/',
plugins: [react()],
server: {
proxy: {
Expand Down
Loading