SAPUI5 보안 완전 가이드 — XSS 방지, CSRF 토큰, CSP 설정부터 XSUAA 연동까지

Moderator · 조회 3
SAPUI5 보안 완전 가이드 SAPUI5 보안 핵심 요소 SAPUI5 보안 가이드 CTA

SAPUI5 보안 완전 가이드 — XSS 방지, CSRF 토큰, CSP 설정부터 XSUAA 연동까지

엔터프라이즈 SAPUI5 애플리케이션에서 반드시 점검해야 할 보안 영역을 체계적으로 다룹니다. 클라이언트 측 XSS 방어부터 서버 측 CSRF 토큰 처리, Content Security Policy 적용, 그리고 SAP BTP 환경의 XSUAA 인증/인가 연동까지 실무 코드와 함께 살펴봅니다.

1. 개요 및 학습 목표

SAPUI5로 구축한 Fiori 앱은 브라우저에서 실행되는 싱글 페이지 애플리케이션(SPA)입니다. SPA 특성상 클라이언트 측에서 HTML을 동적으로 조작하는 빈도가 높아, XSS(Cross-Site Scripting)와 같은 인젝션 공격에 노출되기 쉽습니다. 또한 OData 서비스 호출 시 CSRF(Cross-Site Request Forgery) 토큰을 올바르게 처리하지 않으면 위변조 요청이 통과될 수 있고, CSP(Content Security Policy) 미설정 시 외부 스크립트 삽입 위험이 존재합니다.

이 튜토리얼을 완료하면 다음을 수행할 수 있습니다.

2. 선수 지식

이 가이드를 효과적으로 따라가려면 아래 항목에 대한 기본적인 이해가 필요합니다.

3. 환경 / 버전 / 준비물

항목권장 사양
SAPUI5 버전1.120 LTS 이상 (2024 Q4 기준 최신 LTS)
SAP BTP 에디션Free Tier 또는 Enterprise (Trial 가능)
개발 도구SAP Business Application Studio 또는 VS Code + Fiori Tools
런타임Cloud Foundry 또는 Kyma (Node.js 18+)
서비스XSUAA 서비스 인스턴스, Destination 서비스

로컬 개발 시 ui5 serve 명령어로 개발 서버를 실행하며, --open 플래그와 함께 HTTPS를 활성화하는 것이 보안 테스트에 유리합니다. CSP 헤더 테스트를 위해 Chrome DevTools의 Network 탭과 Console 탭을 함께 활용합니다.

4. 핵심 개념

XSS 방어 — 자동 인코딩이라는 방패

SAPUI5의 표준 컨트롤(예: sap.m.Text, sap.m.Label)은 내부적으로 렌더러가 텍스트를 DOM에 삽입할 때 HTML 엔티티 인코딩을 자동 수행합니다. 이를 비유하면, 모든 택배 상자(사용자 입력)가 배송 센터(렌더러)를 거치면서 자동으로 X-ray 검사를 받는 것과 같습니다. 그러나 sap.ui.core.HTML 컨트롤이나 jQuery.html()처럼 원시 HTML을 직접 삽입하는 경로는 이 검사를 우회하므로, 별도의 수동 검증이 필수입니다.

CSRF 토큰 — 요청의 신원 확인

CSRF 공격은 사용자가 인증된 세션을 가진 상태에서 악의적 페이지가 해당 세션을 도용하여 요청을 보내는 방식입니다. SAPUI5의 OData 모델(sap.ui.model.odata.v2.ODataModel)은 첫 번째 변경(mutation) 요청 전에 자동으로 X-CSRF-Token: Fetch 헤더를 보내 토큰을 획득하고, 이후 POST/PUT/DELETE 요청에 해당 토큰을 자동 첨부합니다. 이를 "은행 창구의 번호표"에 비유할 수 있습니다. 먼저 번호표(토큰)를 받고, 그 번호표를 제시해야만 거래(데이터 변경)가 가능합니다.

CSP — 허용 목록 기반 방화벽

Content Security Policy는 브라우저에게 "이 출처의 스크립트/스타일/이미지만 허용하라"고 지시하는 HTTP 응답 헤더입니다. SAPUI5 앱에서는 UI5 CDN, 자체 도메인, 그리고 OData 서비스 도메인만 허용하는 정책을 설정하는 것이 일반적입니다. unsafe-inlineunsafe-eval을 가능한 한 배제하는 것이 권장되지만, SAPUI5 일부 레거시 기능이 eval을 사용하므로 점진적 적용이 현실적입니다.

XSUAA — BTP 환경의 중앙 인증 관리

SAP Authorization and Trust Management Service(XSUAA)는 OAuth 2.0 기반으로 토큰을 발급하고, 앱 라우터(approuter)가 이를 중개합니다. 사용자 브라우저 → approuter → XSUAA → JWT 토큰 발급 → 백엔드 서비스 검증의 흐름을 따릅니다.

5. 실전 코드 3단계

1단계: 기본 예제 — XSS 안전한 렌더링 vs 위험한 패턴

아래 코드는 사용자 입력을 화면에 표시할 때 안전한 방법과 위험한 방법을 비교합니다.

<!-- XML View: 안전한 패턴 -->
<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m">
  <!-- sap.m.Text는 자동으로 HTML 인코딩 수행 -->
  <Text text="{/userComment}" />

  <!-- sap.m.FormattedText는 허용된 태그만 렌더링 -->
  <FormattedText htmlText="{/safeHtml}" />
</mvc:View>
// Controller: 위험한 패턴 (절대 사용 금지)
onAfterRendering: function () {
  // 위험: 사용자 입력을 innerHTML로 직접 삽입
  var sUserInput = this.getModel().getProperty("/userComment");
  document.getElementById("output").innerHTML = sUserInput;
  // 만약 sUserInput이 "<script>alert('XSS')</script>"라면 스크립트 실행됨
}

// 안전한 패턴: jQuery의 text() 또는 SAPUI5 컨트롤 사용
onAfterRendering: function () {
  var sUserInput = this.getModel().getProperty("/userComment");
  // jQuery.text()는 HTML 태그를 이스케이프 처리
  jQuery("#output").text(sUserInput);
}

// 더 안전한 패턴: SAP 제공 인코딩 유틸리티 사용
sap.ui.require(["sap/base/security/encodeXML"], function (encodeXML) {
  var sSafe = encodeXML(sUserInput);
  // sSafe: "&lt;script&gt;alert('XSS')&lt;/script&gt;"
});

sap/base/security 모듈에는 encodeXML, encodeJS, encodeURL, encodeCSS 등 컨텍스트별 인코딩 함수가 제공됩니다. 출력 위치(HTML 속성, JavaScript 문자열, URL 파라미터 등)에 맞는 인코딩 함수를 선택해야 합니다.

2단계: 실무 시나리오 — CSRF 토큰 수동 처리 (fetch API)

OData 모델을 사용하지 않고 직접 REST API를 호출하는 경우, CSRF 토큰을 수동으로 관리해야 합니다. 아래는 fetch API를 활용한 2단계 토큰 처리 패턴입니다.

sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/m/MessageToast",
  "sap/base/Log"
], function (Controller, MessageToast, Log) {
  "use strict";

  return Controller.extend("myapp.controller.Main", {

    _sCsrfToken: null,

    /**
     * 1단계: CSRF 토큰 Fetch
     * HEAD 또는 GET 요청으로 토큰을 획득한다
     */
    _fetchCsrfToken: async function () {
      try {
        var oResponse = await fetch("/api/service/", {
          method: "HEAD",
          headers: {
            "X-CSRF-Token": "Fetch"
          },
          credentials: "include" // 쿠키 포함 필수
        });

        if (!oResponse.ok) {
          throw new Error("CSRF 토큰 요청 실패: " + oResponse.status);
        }

        this._sCsrfToken = oResponse.headers.get("X-CSRF-Token");
        Log.info("CSRF 토큰 획득 성공");
        return this._sCsrfToken;

      } catch (oError) {
        Log.error("CSRF 토큰 획득 오류", oError.message);
        throw oError;
      }
    },

    /**
     * 2단계: 토큰을 포함한 데이터 변경 요청
     */
    onSavePress: async function () {
      try {
        // 토큰이 없거나 만료되었을 경우 재획득
        if (!this._sCsrfToken) {
          await this._fetchCsrfToken();
        }

        var oPayload = this.getView().getModel().getProperty("/editData");

        var oResponse = await fetch("/api/service/Orders", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "X-CSRF-Token": this._sCsrfToken
          },
          credentials: "include",
          body: JSON.stringify(oPayload)
        });

        // 403: 토큰 만료 → 재시도 1회
        if (oResponse.status === 403) {
          Log.warning("CSRF 토큰 만료, 재획득 시도");
          this._sCsrfToken = null;
          await this._fetchCsrfToken();

          oResponse = await fetch("/api/service/Orders", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "X-CSRF-Token": this._sCsrfToken
            },
            credentials: "include",
            body: JSON.stringify(oPayload)
          });
        }

        if (!oResponse.ok) {
          throw new Error("저장 실패: " + oResponse.status);
        }

        MessageToast.show("저장 완료");

      } catch (oError) {
        Log.error("저장 오류", oError.message);
        MessageToast.show("오류가 발생했습니다: " + oError.message);
      }
    }
  });
});

주요 포인트: credentials: "include"를 반드시 설정해야 세션 쿠키가 전송되어 서버가 토큰과 세션을 매칭할 수 있습니다. 토큰 만료 시 403 응답이 오는 것이 일반적이므로, 1회 자동 재시도 로직을 포함하는 것이 실무에서 권장됩니다.

3단계: 프로덕션 — CSP 설정 및 XSUAA 연동

프로덕션 배포 시 CSP 헤더는 앱 라우터(approuter) 또는 웹 서버 레벨에서 설정합니다.

// xs-app.json (SAP approuter 설정)
{
  "welcomeFile": "/index.html",
  "authenticationMethod": "route",
  "routes": [
    {
      "source": "^/api/(.*)$",
      "target": "$1",
      "destination": "backend-api",
      "authenticationType": "xsuaa",
      "csrfProtection": true
    },
    {
      "source": "^(.*)$",
      "target": "$1",
      "service": "html5-apps-repo-rt",
      "authenticationType": "xsuaa"
    }
  ],
  "responseHeaders": [
    {
      "name": "Content-Security-Policy",
      "value": "default-src 'self'; script-src 'self' https://ui5.sap.com; style-src 'self' 'unsafe-inline' https://ui5.sap.com; font-src 'self' https://ui5.sap.com data:; img-src 'self' https://ui5.sap.com data: blob:; connect-src 'self' https://*.hana.ondemand.com; frame-ancestors 'self' https://*.hana.ondemand.com;"
    },
    {
      "name": "X-Content-Type-Options",
      "value": "nosniff"
    },
    {
      "name": "X-Frame-Options",
      "value": "SAMEORIGIN"
    }
  ]
}
// xs-security.json (XSUAA 서비스 설정)
{
  "xsappname": "myapp-security-demo",
  "tenant-mode": "dedicated",
  "scopes": [
    {
      "name": "$XSAPPNAME.Display",
      "description": "조회 권한"
    },
    {
      "name": "$XSAPPNAME.Admin",
      "description": "관리자 권한"
    }
  ],
  "role-templates": [
    {
      "name": "Viewer",
      "description": "읽기 전용 사용자",
      "scope-references": ["$XSAPPNAME.Display"]
    },
    {
      "name": "Administrator",
      "description": "전체 관리 권한",
      "scope-references": ["$XSAPPNAME.Display", "$XSAPPNAME.Admin"]
    }
  ],
  "role-collections": [
    {
      "name": "MyApp_Viewer",
      "role-template-references": ["$XSAPPNAME.Viewer"]
    },
    {
      "name": "MyApp_Admin",
      "role-template-references": ["$XSAPPNAME.Administrator"]
    }
  ]
}
// Node.js 백엔드에서 JWT 토큰 검증 (CAP 또는 Express 미들웨어)
const xsenv = require("@sap/xsenv");
const passport = require("passport");
const { JWTStrategy } = require("@sap/xssec");

// XSUAA 서비스 바인딩 정보 로드
const xsuaaCredentials = xsenv.getServices({ xsuaa: { tag: "xsuaa" } });

// Passport JWT 전략 등록
passport.use("JWT", new JWTStrategy(xsuaaCredentials.xsuaa));

app.use(passport.initialize());
app.use(passport.authenticate("JWT", { session: false }));

// 스코프 기반 인가 체크
app.get("/api/admin/settings", function (req, res) {
  var sScope = xsuaaCredentials.xsuaa.xsappname + ".Admin";
  if (!req.authInfo.checkScope(sScope)) {
    res.status(403).json({ error: "관리자 권한이 필요합니다" });
    return;
  }
  res.json({ settings: "..." });
});

CSP 설정에서 script-src'unsafe-eval'을 넣지 않는 것이 이상적이나, SAPUI5 1.120 이전 버전에서는 일부 바인딩 표현식이 eval을 사용합니다. SAPUI5 팀에서는 이를 점진적으로 제거하고 있으며, 최신 버전에서는 xx-bindingSyntax: "complex" 설정으로 eval 의존도를 낮출 수 있습니다.

6. 흔한 실수 / 트러블슈팅

FAQ 1: CSP 위반으로 UI5 앱이 아예 로딩되지 않습니다

원인: script-src에 UI5 CDN 도메인을 포함하지 않았거나, 'unsafe-inline' 없이 인라인 이벤트 핸들러가 존재하는 경우입니다. Chrome DevTools Console에서 Refused to execute inline script 메시지를 확인하세요.

해결: 개발 단계에서는 Content-Security-Policy-Report-Only 헤더로 위반 사항만 기록하고, 점진적으로 정책을 강화합니다. UI5 CDN 사용 시 https://ui5.sap.comscript-srcstyle-src에 반드시 추가합니다.

FAQ 2: CSRF 토큰이 계속 403을 반환합니다

원인: 가장 흔한 원인은 credentials 옵션 누락입니다. fetch()는 기본적으로 쿠키를 전송하지 않으므로 서버가 세션을 인식하지 못합니다. 또한 CORS 환경에서는 서버의 Access-Control-Allow-Credentials: true 설정도 필요합니다.

해결: credentials: "include"를 fetch 옵션에 추가하고, 서버 CORS 설정에서 allowCredentials를 활성화합니다. approuter를 경유하면 동일 출처이므로 CORS 이슈를 우회할 수 있습니다.

FAQ 3: XSUAA 토큰에 스코프가 비어 있습니다

원인: Role Collection을 생성했지만 BTP Cockpit에서 사용자에게 할당하지 않은 경우입니다. xs-security.json의 스코프 이름과 코드에서 체크하는 스코프 이름이 일치하지 않는 오타도 빈번합니다.

해결: BTP Cockpit > Security > Users에서 해당 사용자에게 Role Collection을 할당합니다. JWT 토큰을 jwt.io에서 디코딩하여 scope 배열을 직접 확인하는 것이 디버깅에 효과적입니다.

FAQ 4: sap.ui.core.HTML 컨트롤에서 스크립트가 실행됩니다

원인: sap.ui.core.HTML은 기본적으로 sanitizeContent 속성이 false입니다. 사용자 입력이 이 컨트롤에 바인딩되면 스크립트 인젝션이 가능합니다.

해결: sanitizeContent="true"를 설정하거나, 가능하면 sap.m.FormattedText로 대체합니다. FormattedText는 허용된 HTML 태그(b, i, em, strong, a, p, br 등)만 렌더링하고 나머지는 제거합니다.

7. 다음 단계 / 관련 주제

이 가이드에서 다룬 보안 기초를 확보한 후, 아래 주제로 확장하는 것을 권장합니다.

8. 참고 자료