Loading the catalog…
Loading the catalog…
프로그래밍을 처음 배울 때 보안은 보통 비밀번호를 암호화하거나 로그인하지 않은 사용자의 접근을 막는 기능이라고 배운다. if (!currentUser) { throw new UnauthorizedError(); } 로그인하지 않은 요청을 거부하면 보호된 페이지에 접근할 수 없다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 검사 하나만으로 해결할 수 없는 문제가 생긴다. 요청으로 받은 가격과 할인 금액을 믿어도 되는가? 로그인한 사용자가 다른 사람의 주문을 조회할 수 있는가? 레스토랑이 업로드한 파일을 그대로 공개해도 되는가? 결제 서비스가 보낸 웹훅인지 어떻게 확인하는가? 데이터베이스 비밀번호는 어디에 저장하고 언제 교체하는가? 오류와 로그에는 어떤 정보를 남겨도 되는가? 취약한 패키지와 잘못된 운영 설정은 어떻게 발견하는가? 보안은 개발이 끝난 뒤 추가하는 기능이 아니다. 보안은 입력이 들어와 처리되고 저장되며 다른 시스템으로 전달되고 운영 기록에 남는 전체 흐름에서, 허용된 주체만 허용된 행동을 수행하도록 위험을 제한하는 설계 원칙이다. 무엇을 지킬지 모르면 보호 방법도 정할 수 없다 크리스가 음식 배달 서비스를 개발한다고 생각해 보자. 서비스에는 고객, 레스토랑 운영자, 배달 기사, 관리자, 결제 제공업체가 참여한다. 각 주체가 중요하게 여기는 데이터도 다르다. type DeliveryServiceAssets = { customerAddress: string; restaurantBankAccount: string; orderHistory: Order[]; courierLocation: GeoPoint; paymentProviderToken: string; adminSession: string; }; 주소, 계좌 정보, 주문 이력, 위치, 외부 결제 토큰, 관리자 세션은 서로 다른 이유로 보호해야 한다. 보안 설계는 먼저 다음 질문에서 시작한다. 무엇을 보호해야 하는가? 누가 정상적으로 사용할 수 있는가? 어떤 경로로 접근하는가? 잘못 노출되거나 변경되면 어떤 피해가 발생하는가? 시스템이 사용할 수 없게 되면 어떤 업무가 멈추는가? 이를 기밀성, 무결성, 가용성이라는 세 가지 관점으로 나눌 수 있다. 관점 질문 배달 서비스 예시 기밀성 허용되지 않은 사람이 읽을 수 있는가 다른 고객의 주소 노출 무결성 허용되지 않은 방식으로 바뀔 수 있는가 주문 금액이나 환불 계좌 변경 가용성 필요한 때 사용할 수 있는가 주문 API 과부하로 결제 불가 모든 데이터를 같은 수준으로 보호할 필요는 없다. 그러나 데이터의 중요도와 실패 영향을 분류하지 않으면 보호 비용을 어디에 집중해야 하는지도 결정하기 어렵다. 외부에서 들어온 값은 출처와 상관없이 검증해야 한다 주문 생성 API가 클라이언트의 요청을 그대로 사용한다고 생각해 보자. await orderRepository.create({ customerId: request.body.customerId, restaurantId: request.body.restaurantId, totalPrice: request.body.totalPrice, status: request.body.status, }); 사용자는 다른 사람의 customerId , 더 낮은 totalPrice , 허용되지 않은 status 를 직접 보낼 수 있다. 외부 입력은 검증 전까지 신뢰할 수 없다. 브라우저 폼뿐 아니라 다음 값도 외부 입력이다. URL 경로와 쿼리 HTTP 헤더와 쿠키 모바일 애플리케이션의 요청 외부 결제 서비스의 웹훅 메시지 큐의 이벤트 업로드 파일 다른 내부 서비스의 응답 운영자가 입력한 관리 데이터 먼저 요청의 구조를 검증한다. import { z } from "zod"; const CreateOrderSchema = z.object({ restaurantId: z.string().uuid(), deliveryAddressId: z.string().uuid(), items: z .array( z.object({ menuItemId: z.string().uuid(), quantity: z.number().int().min(1).max(20), }) ) .min(1) .max(50), couponCode: z .string() .trim() .max(40) .optional(), }); const input = CreateOrderSchema.parse(request.body); 이 검증은 허용된 필드, 타입, 길이, 수량 범위를 제한한다. 고객 ID, 가격, 주문 상태는 클라이언트가 결정할 값이 아니므로 요청 스키마에 포함하지 않는다. 형식이 올바르다고 비즈니스 의미까지 올바른 것은 아니다. const restaurant = await restaurantRepository.findOpenById( input.restaurantId ); if (!restaurant) { throw new RestaurantUnavailableError(); } UUID 형식이 맞더라도 실제 영업 중인 레스토랑인지 확인해야 한다. 입력 검증은 두 단계로 확장된다. 문법적 검증은 타입, 형식, 길이, 범위를 확인한다. 의미적 검증은 현재 서비스 상태에서 허용되는 값인지 확인한다. 검증만으로 입력이 안전한 명령이 되지는 않는다 메뉴 검색어의 길이와 타입을 검증했다고 생각해 보자. const SearchSchema = z.object({ keyword: z.string().trim().min(1).max(100), }); 이 검증은 비정상적으로 긴 입력을 막지만 문자열이 SQL의 일부로 실행되는 문제까지 해결하지는 않는다. 다음 코드는 검색어를 SQL 문자열에 직접 연결한다. const query = ` SELECT * FROM menu_items WHERE name LIKE '%${keyword}%' `; 입력값이 데이터가 아니라 SQL 문법으로 해석될 수 있다. 매개변수화된 쿼리를 사용해야 한다. const menuItems = await database.query( ` SELECT * FROM menu_items WHERE name ILIKE $1 `, [`%${keyword}%`] ); 검색어는 SQL 명령이 아니라 하나의 데이터 값으로 전달된다. 화면 출력도 사용하는 위치에 맞게 처리해야 한다. <p>{restaurant.description}</p> React처럼 기본적으로 텍스트를 이스케이프하는 렌더링 방식을 사용하면 설명에 포함된 HTML이 코드로 실행되는 것을 줄일 수 있다. 반대로 검증되지 않은 HTML을 직접 삽입하는 방식은 위험을 만든다. <div dangerouslySetInnerHTML={{ __html: restaurant.description, }} /> 입력 검증, 매개변수화된 쿼리, 출력 인코딩은 서로 다른 경계를 보호한다. 하나를 적용했다고 나머지가 필요 없어지는 것은 아니다. 가격의 Source of Truth는 사용자의 화면이 아니다 프론트엔드는 주문 예상 금액을 계산해 보여줄 수 있다. const displayedTotal = cartItems.reduce( (total, item) => total + item.price * item.quantity, 0 ); 이 값은 사용자에게 예상 금액을 빠르게 보여주는 데 유용하다. 그러나 사용자는 브라우저의 상태와 요청 본문을 변경할 수 있다. { "restaurantId": "restaurant-123", "totalPrice": 1 } 서버가 이 금액을 신뢰하면 사용자가 임의로 가격을 정할 수 있다. 서버가 현재 메뉴 가격과 할인 정책으로 다시 계산해야 한다. const pricedItems = await menuRepository.findOrderableItems( input.items.map((item) => item.menuItemId) ); const orderTotal = calculateOrderTotal({ requestedItems: input.items, pricedItems, coupon, deliveryFeePolicy, }); 서버의 메뉴 정보와 할인 정책이 주문 금액의 Source of Truth다. 프론트엔드의 계산값은 표시를 위한 파생 값이다. 보안 문제는 특수문자를 입력하는 공격만을 의미하지 않는다. 정상적인 API를 비정상적인 순서나 값으로 사용해 비즈니스 규칙을 우회하는 것도 보안 문제다. 다음 규칙도 서버에서 다시 확인해야 한다. 쿠폰이 현재 사용자에게 발급되었는가? 쿠폰이 해당 레스토랑에 적용되는가? 최소 주문 금액을 만족하는가? 같은 쿠폰을 이미 사용하지 않았는가? 메뉴가 주문 가능한 상태인가? 요청한 수량이 구매 제한을 넘지 않는가? 보안은 입력의 모양뿐 아니라 사용자가 서비스의 약속을 우회할 수 있는지도 확인해야 한다. 인증과 권한은 모든 중요한 행동에서 다시 만난다 고객이 로그인했다는 사실만으로 모든 주문을 조회할 수 있는 것은 아니다. const order = await orderRepository.findById( request.params.orderId ); 다른 고객의 주문 ID를 전달하면 주소와 주문 내역이 노출될 수 있다. 현재 사용자의 범위를 함께 적용해야 한다. const order = await orderRepository.findOne({ id: orderId, customerId: currentUser.id, }); if (!order) { throw new OrderNotFoundError(); } 고객은 자신의 주문만 조회할 수 있다. 레스토랑 운영자는 같은 주문을 다른 관계로 조회할 수 있다. const order = await orderRepository.findOne({ id: orderId, restaurantId: currentRestaurantMembership.restaurantId, }); 배달 기사에게는 자신에게 배정된 주문만 보여줄 수 있다. const delivery = await deliveryRepository.findOne({ orderId, courierId: currentCourier.id, }); 같은 주문이라도 주체마다 허용된 행동과 필드가 다르다. 주체 허용할 수 있는 행동 제한할 정보 고객 자신의 주문 조회·취소 내부 운영 메모 레스토랑 주문 접수·조리 상태 변경 전체 결제 정보 배달 기사 배정 주문과 배송지 확인 고객의 다른 주문 이력 고객 지원 문의 해결을 위한 제한 조회 결제 비밀값 관리자 정책상 필요한 관리 작업 불필요한 비밀번호·토큰 원문 인증은 사용자가 누구인지 확인한다. 권한은 그 사용자가 해당 주문에 특정 행동을 할 수 있는지 결정한다. 최소 권한은 사고가 발생했을 때 피해 범위를 줄인다 주문 서비스가 하나의 데이터베이스 계정으로 모든 테이블을 읽고 수정한다고 생각해 보자. order-service → users: read/write → restaurants: read/write → orders: read/write → payments: read/write → audit_logs: read/write 주문 서비스가 침해되면 필요하지 않은 사용자와 감사 로그 데이터까지 변경할 수 있다. 업무에 필요한 권한만 제공하는 편이 낫다. order-service → menu_items: read → orders: read/write → payment_records: create/read-status → audit_logs: append-only 애플리케이션 코드에서도 같은 원칙이 필요하다. type RestaurantOrderView = { orderId: string; items: OrderItem[]; requestedDeliveryTime: string; deliveryAddressSummary: string; }; 레스토랑 화면에 전체 고객 프로필이나 결제 토큰을 전달하지 않는다. 최소 권한은 정상 업무를 어렵게 만드는 것이 아니다. 계정, 서비스, 기능, 데이터 필드가 실제 책임보다 넓은 접근권을 가지지 않게 하는 일이다. 민감한 데이터는 수집하기 전부터 삭제까지 책임이 생긴다 배달 서비스를 만들 때 필요할 가능성이 있다는 이유로 모든 정보를 수집할 수 있다. type CustomerProfile = { fullName: string; dateOfBirth: string; homeAddress: string; workAddress: string; phoneNumber: string; locationHistory: GeoPoint[]; }; 그러나 수집한 데이터마다 저장, 권한, 암호화, 보존, 삭제, 사고 대응 책임이 생긴다. 서비스에 생년월일과 전체 위치 이력이 필요하지 않다면 수집하지 않는 편이 안전하다. type DeliveryAddress = { recipientName: string; phoneNumber: string; streetAddress: string; deliveryInstructions: string | null; }; 현재 배송에 필요한 정보만 저장한다. 데이터별 책임도 구분해야 한다. 데이터 보호 방법 보존 기준 비밀번호 전용 비밀번호 해싱 계정 자격 증명 갱신까지 배송지 암호화·접근 통제 사용자 설정과 법적 요구에 따라 결제 카드 제공업체 토큰 사용 원문 카드 저장 회피 주문 내역 권한·무결성·감사 거래 및 법적 보존 정책 실시간 기사 위치 최소 접근·짧은 보존 배송 수행에 필요한 기간 세션 토큰 안전한 저장·만료·철회 세션 생명주기 데이터를 보호하는 가장 단순한 방법 중 하나는 필요하지 않은 데이터를 보유하지 않는 것이다. 통신 경계마다 상대방과 메시지를 검증해야 한다 고객의 브라우저와 서버 사이에서 HTTPS를 사용하더라도 서버와 다른 시스템 사이의 통신이 자동으로 안전해지는 것은 아니다. flowchart TD A[고객 앱] --> B[배달 서비스 API] B --> C[결제 제공업체] B --> D[알림 서비스] B --> E[레스토랑 시스템] 각 연결에는 별도의 인증, 암호화, 권한, 시간 제한이 필요하다. 결제 웹훅이 들어왔다고 생각해 보자. await paymentService.markPaid( request.body.orderId ); 요청을 보낸 주체를 확인하지 않으면 누구나 주문을 결제 완료 상태로 바꿀 수 있다. 웹훅 서명을 검증해야 한다. const event = paymentProvider.verifyWebhook({ rawBody: request.rawBody, signature: request.headers["payment-signature"], }); 이 코드는 결제 제공업체의 공식 검증 방식으로 요청의 출처와 변경 여부를 확인한다. 그다음 이벤트의 의미를 검증한다. if (event.type !== "payment.completed") { return; } const payment = await paymentRepository.findByProviderId( event.paymentId ); if ( !payment || payment.expectedAmount !== event.amount ) { throw new PaymentVerificationError(); } 서명이 유효해도 이벤트 유형, 주문과의 관계, 금액, 통화, 중복 처리 여부를 확인해야 한다. 외부 서비스에서 왔다는 사실과 현재 비즈니스 작업에 사용할 수 있다는 사실은 다르다. 비밀값은 코드에 숨기는 문자열이 아니라 생명주기를 가진 자격 증명이다 데이터베이스 비밀번호와 외부 API 키를 코드에 작성하면 저장소와 배포 결과에 남는다. const paymentApiKey = "live_payment_secret"; 환경변수로 옮기면 코드와 설정을 분리할 수 있다. const paymentApiKey = process.env.PAYMENT_API_KEY; 하지만 환경변수라는 위치만으로 비밀 관리가 완성되지는 않는다. 다음 질문도 필요하다. 값은 누가 생성하는가? 개발자 개인에게 원문이 필요한가? 어느 서비스와 환경에서 사용할 수 있는가? 언제 만료되고 교체되는가? 노출되었을 때 어떻게 철회하는가? 사용 기록을 확인할 수 있는가? 이전 값에서 새 값으로 어떻게 전환하는가? 비밀 관리 시스템에서 실행 시점에 값을 받을 수 있다. const paymentApiKey = await secretManager.getSecret( "production/payment-api-key" ); 애플리케이션은 필요한 비밀만 읽을 수 있어야 한다. await authorizationPolicy.assertServiceCanRead({ service: "payment-worker", secret: "production/payment-api-key", }); 비밀값을 로그에 남기지 않아야 한다. logger.info("Payment client configured", { provider: "payment-provider", keyVersion, }); 어떤 제공업체와 키 버전을 사용했는지는 기록하되 원문 키는 기록하지 않는다. 오류는 내부 구조를 숨기면서도 복구 가능해야 한다 데이터베이스 오류를 그대로 사용자에게 반환하면 내부 정보가 노출될 수 있다. return response.status(500).json({ error: error.stack, query: error.query, }); 스택, SQL, 테이블 이름, 서버 경로가 외부 응답에 포함될 수 있다. 외부에는 안정적인 오류 형식을 제공한다. return response.status(500).json({ code: "ORDER_PROCESSING_FAILED", message: "주문을 처리하지 못했다. 잠시 후 다시 시도해 달라.", requestId, }); requestId 를 사용하면 사용자는 내부 세부정보 없이 고객 지원에 문제를 전달할 수 있다. 내부 로그에는 조사에 필요한 정보를 남긴다. logger.error("Order processing failed", { requestId, orderId, errorName: error.name, }); 그러나 다음 값은 로그에서 제외해야 한다. 비밀번호와 인증 토큰 결제 카드 원문 API 키와 암호화 키 전체 배송지와 불필요한 개인정보 쿠키와 세션 ID 원문 요청 본문 전체 보안 오류 처리는 정보를 모두 숨기는 작업이 아니다. 사용자에게는 안전한 복구 정보를, 운영자에게는 민감정보를 제외한 조사 정보를 제공하는 일이다. 로그는 공격을 발견할 수 있어야 하지만 새로운 유출 경로가 되어서는 안 된다 정상 요청만 기록하면 공격 시도를 추적하기 어렵다. 다음 사건은 보안 로그의 후보가 된다. 반복된 로그인 실패 다른 고객 주문에 대한 접근 거부 관리자 역할 변경 쿠폰 중복 사용 시도 웹훅 서명 검증 실패 비정상적으로 많은 주문 생성 비밀값 접근과 교체 민감한 데이터 내보내기 구조화된 보안 이벤트를 남길 수 있다. securityLogger.warn( "order_access_denied", { actorId: currentUser.id, orderId, requestId, ipAddressHash: hashForSecurityAnalysis(ipAddress), occurredAt: new Date().toISOString(), } ); 이 로그는 누가 어떤 주문에 접근하려 했는지 조사할 수 있게 한다. 원본 인증 토큰과 전체 요청은 포함하지 않는다. 로그도 보호 대상 데이터다. 로그를 읽을 수 있는 사람을 제한한다. 로그의 변경과 삭제를 통제한다. 서버 시간을 동기화한다. 보존 기간을 정한다. 경고 기준과 대응 담당자를 정한다. 민감정보 마스킹을 테스트한다. 기록만 하고 아무도 확인하지 않는 로그는 탐지 체계가 아니다. 파일 업로드는 저장 기능이 아니라 새로운 실행 경계를 여는 기능이다 레스토랑 운영자가 메뉴 이미지를 업로드할 수 있다고 생각해 보자. await fileStorage.save({ fileName: upload.originalName, content: upload.buffer, }); 사용자가 정한 파일명을 그대로 사용하고 파일 내용을 확인하지 않으면 경로 조작, 실행 파일 업로드, 과도한 크기의 파일 같은 문제가 생길 수 있다. 허용 범위를 명시해야 한다. const MenuImageSchema = z.object({ size: z.number().max(5 * 1024 * 1024), detected
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
보안은 개발이 끝난 뒤 추가하는 기능이 아니다. 프로그래밍을 처음 배울 때 보안은 보통 비밀번호를 암호화하거나 로그인하지 않은 사용자의 접근을 막는 기능이라고 배운다. if (!currentUser) { throw new UnauthorizedError(); } 로그인하지 않은 요청을 거부하면 보호된 페이지에 접근할 수 없다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 검사 하나만으로 해결할 수 없는 문제가 생긴다. 요청으로 받은 가격과 할인 금액을 믿어도 되는가? 로그인한 사용자가 다른 사람의 주문을 조회할 수 있는가? 레스토랑이 업로드한 파일을 그대로 공개해도 되는가? 결제 서비스가 보낸 웹훅인지 어떻게 확인하는가? 데이터베이스 비밀번호는 어디에 저장하고 언제…
Open source