From faf365e0c649674bc29c01092ac55b5406149a8b Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 25 Feb 2026 08:22:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20SSO=20=EB=8D=B0=EB=93=9C?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=A0=9C=EA=B1=B0=20+=20open=20redirect?= =?UTF-8?q?=20=EB=B0=A9=EC=96=B4=20+=20system2=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSO auth: 서버측 httpOnly 쿠키 제거 (클라이언트 domain cookie로 대체) - SSO auth: extractToken()에서 미작동 req.cookies 코드 제거 - Gateway login.html: redirect 파라미터 open redirect 취약점 방어 - System 2: 인라인 requireAuth → middlewares/auth.js 사용 - System 2: 404/에러 핸들러 등록 순서 수정 (Express 모범사례) - .gitignore: *.bak* 패턴 추가 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + gateway/html/login.html | 7 +++- .../controllers/authController.js | 24 ++---------- system2-report/api/index.js | 37 ++++--------------- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index 82d85fc..e314ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ __pycache__/ *.pyc +*.bak* .env *.tar.gz uploads/ diff --git a/gateway/html/login.html b/gateway/html/login.html index ccd0ba3..af399cb 100644 --- a/gateway/html/login.html +++ b/gateway/html/login.html @@ -161,7 +161,12 @@ // redirect 파라미터가 있으면 해당 URL로, 없으면 포털로 var redirect = new URLSearchParams(location.search).get('redirect'); - window.location.href = redirect || '/'; + // Open redirect 방지: 상대 경로 또는 같은 도메인만 허용 + if (redirect && (redirect.startsWith('/') && !redirect.startsWith('//')) && !redirect.includes('://')) { + window.location.href = redirect; + } else { + window.location.href = '/'; + } } catch (err) { errEl.textContent = err.message; errEl.style.display = ''; diff --git a/sso-auth-service/controllers/authController.js b/sso-auth-service/controllers/authController.js index d88b468..04c2ada 100644 --- a/sso-auth-service/controllers/authController.js +++ b/sso-auth-service/controllers/authController.js @@ -65,14 +65,8 @@ async function login(req, res, next) { { expiresIn: JWT_REFRESH_EXPIRES_IN } ); - // SSO 쿠키 설정 (동일 도메인 공유) - res.cookie('sso_token', access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days - path: '/' - }); + // SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정 + // 서버 httpOnly 쿠키는 서브도메인 공유 불가하므로 제거 res.json({ success: true, @@ -237,14 +231,6 @@ async function refresh(req, res, next) { { expiresIn: JWT_REFRESH_EXPIRES_IN } ); - res.cookie('sso_token', access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 7 * 24 * 60 * 60 * 1000, - path: '/' - }); - res.json({ success: true, access_token, @@ -335,15 +321,11 @@ async function deleteUser(req, res, next) { * Bearer 토큰 또는 쿠키에서 토큰 추출 */ function extractToken(req) { - // Authorization header + // Authorization header (SSO 토큰은 항상 Bearer로 전달) const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.split(' ')[1]; } - // Cookie - if (req.cookies && req.cookies.sso_token) { - return req.cookies.sso_token; - } return null; } diff --git a/system2-report/api/index.js b/system2-report/api/index.js index 4f9d8e8..6f270e7 100644 --- a/system2-report/api/index.js +++ b/system2-report/api/index.js @@ -45,31 +45,8 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'system2-report', timestamp: new Date().toISOString() }); }); -// JWT Auth middleware (SSO 공유 시크릿) -const jwt = require('jsonwebtoken'); -const requireAuth = (req, res, next) => { - try { - const authHeader = req.headers['authorization']; - if (!authHeader) { - throw new AuthenticationError('Authorization 헤더가 필요합니다'); - } - const token = authHeader.split(' ')[1]; - if (!token) { - throw new AuthenticationError('Bearer 토큰이 필요합니다'); - } - const decoded = jwt.verify(token, process.env.JWT_SECRET); - req.user = decoded; - next(); - } catch (err) { - if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { - return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' }); - } - if (err instanceof AuthenticationError) { - return res.status(401).json({ success: false, error: err.message }); - } - next(err); - } -}; +// JWT Auth middleware (middlewares/auth.js 사용) +const { requireAuth } = require('./middlewares/auth'); // Routes const workIssueRoutes = require('./routes/workIssueRoutes'); @@ -77,6 +54,11 @@ const workIssueRoutes = require('./routes/workIssueRoutes'); // 인증이 필요한 API app.use('/api/work-issues', requireAuth, workIssueRoutes); +// 404 (에러 핸들러보다 먼저 등록) +app.use((req, res) => { + res.status(404).json({ success: false, error: 'Not Found', path: req.originalUrl }); +}); + // Error handler app.use((err, req, res, next) => { const statusCode = err.statusCode || 500; @@ -87,11 +69,6 @@ app.use((err, req, res, next) => { }); }); -// 404 -app.use((req, res) => { - res.status(404).json({ success: false, error: 'Not Found', path: req.originalUrl }); -}); - app.listen(PORT, () => { console.log(`System 2 (신고) API running on port ${PORT}`); });