fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정

sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
This commit is contained in:
HeuJung committed 2026-08-17 15:46:38 +09:00
1 parent 2b5972437b
commit cf1d8ab52c
84 files changed
+3195 -246

No files matched your search

@@ -6,6 +6,10 @@
## [1.0.2] - 2026-08-12
### Security
- 에디터 이미지 업로드의 권한 검사가 클라이언트가 보낸 값에 좌우되어, 그 값이 비어 있으면 검사가 통째로 건너뛰어지던 구조를 제거했습니다. 업로드는 종전처럼 관리자만 할 수 있으며, 이제 그 판정은 전적으로 서버가 합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
### Added
- 에디터 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 업로드 이미지를 저장 전에 변환할 수 있습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-ckeditor5",
"version": "1.0.1",
"version": "1.0.2",
"components": {
"basic": [],
"composite": [],
+2 -2
View File
@@ -332,7 +332,7 @@ var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.cke
.ck.ck-editor__editable li::marker {
color: #d1d5db;
}
`,document.head.appendChild(o)}let x=null;function ot(){x||(x=new MutationObserver(()=>{q()}),x.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}))}function nt(){x&&(x.disconnect(),x=null)}const rt={ko:"한국어",en:"English",ja:"日本語",zh:"中文",fr:"Français",de:"Deutsch",es:"Español"};function ct(t){return rt[t]??t.toUpperCase()}function lt(){const t=window.G7Core;return t?.locale?.supported?t.locale.supported():[localStorage.getItem("g7_locale")||"ko"]}function I(){return window.G7Core?.locale?.current?.()??localStorage.getItem("g7_locale")??"ko"}function at(t,o,e){const r=[t.Essentials,t.Bold,t.Italic,t.Underline,t.Strikethrough,t.Link,t.Paragraph,t.Heading,t.BlockQuote,t.List,t.Alignment,t.Table,t.TableToolbar,t.TableProperties,t.TableCellProperties,t.Indent,t.IndentBlock,t.PasteFromOffice,t.GeneralHtmlSupport,t.Image,t.ImageResize,t.ImageStyle,t.ImageCaption,t.ImageToolbar];return o==="full"&&r.push(t.MediaEmbed,t.HorizontalLine,t.CodeBlock,t.SourceEditing,t.FontSize,t.FontColor,t.FontBackgroundColor),e&&r.push(t.ImageUpload,t.SimpleUploadAdapter),r}function it(t){const o="/api/plugins/sirsoft-ckeditor5/upload";return t?`${o}?permission=${encodeURIComponent(t)}`:o}function dt(){const t=localStorage.getItem("auth_token");return t?{Authorization:`Bearer ${t}`}:{}}async function F(t,o){const e=window.CKEDITOR;if(!e)throw new Error("[sirsoft-ckeditor5] CKEDITOR 전역 객체를 찾을 수 없습니다. CDN 스크립트가 로드되었는지 확인하세요.");const r=O[o.toolbar]||O.standard,c=o.imageUpload?r:r.filter(n=>n!=="uploadImage"),l={initialData:o.initialContent,plugins:at(e,o.toolbar,o.imageUpload),toolbar:{items:c},table:{contentToolbar:["tableColumn","tableRow","mergeTableCells","|","tableProperties","tableCellProperties"],tableProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",alignment:"left"}},tableCellProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",padding:"8px"}}},image:{toolbar:["imageStyle:inline","imageStyle:alignLeft","imageStyle:alignCenter","imageStyle:alignRight","|","toggleImageCaption","|","resizeImage"],resizeOptions:[{name:"resizeImage:original",label:"Original",value:null},{name:"resizeImage:25",label:"25%",value:"25"},{name:"resizeImage:50",label:"50%",value:"50"},{name:"resizeImage:75",label:"75%",value:"75"}],resizeUnit:"%"},indentBlock:{offset:40,unit:"px"},link:{defaultProtocol:"https://",decorators:{openInNewTab:{mode:"automatic",callback:n=>n.startsWith("http://")||n.startsWith("https://"),attributes:{target:"_blank",rel:"noopener noreferrer"}}}},htmlSupport:{allow:[{name:/.*/,attributes:!0,classes:!0,styles:!0}]},placeholder:o.placeholder,language:I()};o.imageUpload&&(l.simpleUpload={uploadUrl:it(o.uploadPermission),headers:dt()});const i=await e.ClassicEditor.create(t,l),s=`ckeditor5-height-style-${o.containerId}`,b=document.getElementById(s);if(b)b.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`;else{const n=document.createElement("style");n.id=s,n.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`,document.head.appendChild(n)}return o.readOnly&&i.enableReadOnlyMode("read-only"),i}function st(t,o,e,r,c){const l=document.createElement("div");l.className="ckeditor5-locale-tabs";const i=new Map;o.forEach(n=>{const a=document.createElement("div");a.dataset.locale=n,n!==e&&(a.style.cssText="display:none;");const C=document.createElement("div");a.appendChild(C),i.set(n,C)});const s=()=>{c&&l.querySelectorAll("button").forEach(n=>{const a=n.dataset.locale;if(!a||n.classList.contains("is-active")){const h=n.querySelector(".ck5-check");h&&h.remove();return}const g=!!c(a)?.trim(),p=n.querySelector(".ck5-check");if(g&&!p){const h=document.createElement("i");h.className="fas fa-check ck5-check",n.appendChild(h)}else!g&&p&&p.remove()})},b=o[0];return o.forEach(n=>{const a=n===b,C=n===e,g=document.createElement("button");g.type="button",g.dataset.locale=n,g.className=[C?"is-active":"",a?"is-default":""].filter(Boolean).join(" "),g.innerHTML=`<i class="fas fa-globe ck5-lang-icon"></i><span>${ct(n)}</span>${a?'<span class="ck5-required">*</span>':""}`,g.addEventListener("click",async()=>{l.querySelectorAll("button").forEach(p=>{const L=p.dataset.locale===n;p.classList.toggle("is-active",L)}),i.forEach((p,h)=>{const L=p.parentElement;L&&(L.style.display=h===n?"block":"none")}),r&&await r(n),s()}),l.appendChild(g)}),t.appendChild(l),i.forEach(n=>{n.parentElement&&t.appendChild(n.parentElement)}),{tabsEl:l,contentEls:i,updateCheckIcons:s}}function T(t,o,e,r){const c=window.G7Core;if(!c?.state?.setLocal)return;const l={[`form.${t}_mode`]:"html",hasChanges:!0};r?l[`form.${t}.${o}`]=e:l[`form.${t}`]=e,c.state.setLocal(l,{debounce:300,debounceKey:`ckeditor-sync-${t}`,render:!1,selfManaged:!0})}async function ut(t,o){const e=t.params??{},c=`ckeditor5-${e.name??"content"}`,l=E.get(cLine truncated
`,document.head.appendChild(o)}let x=null;function ot(){x||(x=new MutationObserver(()=>{q()}),x.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}))}function nt(){x&&(x.disconnect(),x=null)}const rt={ko:"한국어",en:"English",ja:"日本語",zh:"中文",fr:"Français",de:"Deutsch",es:"Español"};function ct(t){return rt[t]??t.toUpperCase()}function lt(){const t=window.G7Core;return t?.locale?.supported?t.locale.supported():[localStorage.getItem("g7_locale")||"ko"]}function I(){return window.G7Core?.locale?.current?.()??localStorage.getItem("g7_locale")??"ko"}function at(t,o,e){const r=[t.Essentials,t.Bold,t.Italic,t.Underline,t.Strikethrough,t.Link,t.Paragraph,t.Heading,t.BlockQuote,t.List,t.Alignment,t.Table,t.TableToolbar,t.TableProperties,t.TableCellProperties,t.Indent,t.IndentBlock,t.PasteFromOffice,t.GeneralHtmlSupport,t.Image,t.ImageResize,t.ImageStyle,t.ImageCaption,t.ImageToolbar];return o==="full"&&r.push(t.MediaEmbed,t.HorizontalLine,t.CodeBlock,t.SourceEditing,t.FontSize,t.FontColor,t.FontBackgroundColor),e&&r.push(t.ImageUpload,t.SimpleUploadAdapter),r}function it(){return"/api/plugins/sirsoft-ckeditor5/upload"}function dt(){const t=localStorage.getItem("auth_token");return t?{Authorization:`Bearer ${t}`}:{}}async function F(t,o){const e=window.CKEDITOR;if(!e)throw new Error("[sirsoft-ckeditor5] CKEDITOR 전역 객체를 찾을 수 없습니다. CDN 스크립트가 로드되었는지 확인하세요.");const r=O[o.toolbar]||O.standard,c=o.imageUpload?r:r.filter(n=>n!=="uploadImage"),l={initialData:o.initialContent,plugins:at(e,o.toolbar,o.imageUpload),toolbar:{items:c},table:{contentToolbar:["tableColumn","tableRow","mergeTableCells","|","tableProperties","tableCellProperties"],tableProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",alignment:"left"}},tableCellProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",padding:"8px"}}},image:{toolbar:["imageStyle:inline","imageStyle:alignLeft","imageStyle:alignCenter","imageStyle:alignRight","|","toggleImageCaption","|","resizeImage"],resizeOptions:[{name:"resizeImage:original",label:"Original",value:null},{name:"resizeImage:25",label:"25%",value:"25"},{name:"resizeImage:50",label:"50%",value:"50"},{name:"resizeImage:75",label:"75%",value:"75"}],resizeUnit:"%"},indentBlock:{offset:40,unit:"px"},link:{defaultProtocol:"https://",decorators:{openInNewTab:{mode:"automatic",callback:n=>n.startsWith("http://")||n.startsWith("https://"),attributes:{target:"_blank",rel:"noopener noreferrer"}}}},htmlSupport:{allow:[{name:/.*/,attributes:!0,classes:!0,styles:!0}]},placeholder:o.placeholder,language:I()};o.imageUpload&&(l.simpleUpload={uploadUrl:it(),headers:dt()});const i=await e.ClassicEditor.create(t,l),s=`ckeditor5-height-style-${o.containerId}`,m=document.getElementById(s);if(m)m.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`;else{const n=document.createElement("style");n.id=s,n.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`,document.head.appendChild(n)}return o.readOnly&&i.enableReadOnlyMode("read-only"),i}function st(t,o,e,r,c){const l=document.createElement("div");l.className="ckeditor5-locale-tabs";const i=new Map;o.forEach(n=>{const a=document.createElement("div");a.dataset.locale=n,n!==e&&(a.style.cssText="display:none;");const C=document.createElement("div");a.appendChild(C),i.set(n,C)});const s=()=>{c&&l.querySelectorAll("button").forEach(n=>{const a=n.dataset.locale;if(!a||n.classList.contains("is-active")){const h=n.querySelector(".ck5-check");h&&h.remove();return}const g=!!c(a)?.trim(),p=n.querySelector(".ck5-check");if(g&&!p){const h=document.createElement("i");h.className="fas fa-check ck5-check",n.appendChild(h)}else!g&&p&&p.remove()})},m=o[0];return o.forEach(n=>{const a=n===m,C=n===e,g=document.createElement("button");g.type="button",g.dataset.locale=n,g.className=[C?"is-active":"",a?"is-default":""].filter(Boolean).join(" "),g.innerHTML=`<i class="fas fa-globe ck5-lang-icon"></i><span>${ct(n)}</span>${a?'<span class="ck5-required">*</span>':""}`,g.addEventListener("click",async()=>{l.querySelectorAll("button").forEach(p=>{const L=p.dataset.locale===n;p.classList.toggle("is-active",L)}),i.forEach((p,h)=>{const L=p.parentElement;L&&(L.style.display=h===n?"block":"none")}),r&&await r(n),s()}),l.appendChild(g)}),t.appendChild(l),i.forEach(n=>{n.parentElement&&t.appendChild(n.parentElement)}),{tabsEl:l,contentEls:i,updateCheckIcons:s}}function T(t,o,e,r){const c=window.G7Core;if(!c?.state?.setLocal)return;const l={[`form.${t}_mode`]:"html",hasChanges:!0};r?l[`form.${t}.${o}`]=e:l[`form.${t}`]=e,c.state.setLocal(l,{debounce:300,debounceKey:`ckeditor-sync-${t}`,render:!1,selfManaged:!0})}async function ut(t,o){const e=t.params??{},c=`ckeditor5-${e.name??"content"}`,l=E.get(c);if(l&&l.size>0){const y=[];l.forEach(u=>{u&&typeof u.destroy=="function"&Line truncated
/* 이미지 캡션 - 다크모드 */
html.dark .ck-content .image > figcaption { color: hsl(0, 0%, 80%); background-color: hsl(0, 0%, 15%); }
@@ -369,4 +369,4 @@ var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.cke
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:bt},z=window.G7Core?.createLogger?.(`Plugin:${b}`)??{log:(...t)=>console.log(`[Plugin:${b}]`,...t),warn:(...t)=>console.warn(`[Plugin:${b}]`,...t),error:(...t)=>console.error(`[Plugin:${b}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${b}.${e}`;o.registerHandler(c,r,{category:"plugin",source:b})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${b}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const m=`${b}.${i}`;l.registerHandler(m,s,{category:"plugin",source:b})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${b}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:b,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
@@ -73,7 +73,7 @@ _이 엔드포인트는 표준 envelope(`success`/`message`/`data`)를 사용하
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | query 파라미터 `permission` 이 지정됐고 현재 사용자가 해당 권한을 갖지 못한 경우 — `{"error":{"message":"이미지 업로드 권한이 없습니다."}}` |
| 403 | Forbidden | 관리자(admin) 권한이 없는 경우 (라우트의 `admin` 미들웨어) |
| 422 | Unprocessable Entity | 검증 규칙 위반 (`upload` 누락 / 파일 아님 / 이미지 아님 / 허용 MIME 아님 / 용량 초과). 응답은 Laravel 기본 `errors` 형식이 아니라 CKEditor 규격 `{"error":{"message":"<첫 번째 오류 메시지>"}}` |
| 500 | Internal Server Error | 스토리지 저장 또는 업로드 기록 생성 중 예외 발생 — `{"error":{"message":"이미지 업로드에 실패했습니다."}}` |
@@ -86,7 +86,7 @@ CKEditor5 의 SimpleUploadAdapter 가 에디터에 드롭/붙여넣은 이미지
- **응답 형식이 표준 envelope 가 아니다.** SimpleUploadAdapter 규격상 성공 시 HTTP 201 + 최상위 `{"url": "..."}`, 실패 시 4xx/5xx + `{"error": {"message": "..."}}` 를 반환한다. `ResponseHelper` 를 쓰지 않으므로 `data`/`success` 필드가 없다.
이 규격은 우리 코드가 정한 것이 아니다 — 응답을 파싱하는 주체가 CDN 으로 로드되는 CKEditor5 43.3.1 의 `SimpleUploadAdapter`(`resources/js/handlers/initEditor.ts` 의 `editorConfig.simpleUpload`)이므로 파싱 규약을 바꿀 수 없다. 컨트롤러의 각 응답 지점에는 이 사유로 `audit:allow response-helper-bypass` 를 명시해 두었으니, envelope 로 감싸는 "수정" 을 하면 에디터의 이미지 업로드가 조용히 실패한다.
- **요청 파라미터**: multipart body 의 `upload` 필드(이미지 파일 1개). 허용 MIME 은 `jpeg,jpg,png,gif,webp`, 최대 크기는 플러그인 설정 `imageMaxSizeMb`(기본 2MB) 로 동적 결정된다. 검증 실패도 CKEditor 규격(`{"error":{"message":...}}`, HTTP 422)으로 응답한다.
- **선택 권한 게이트**: query 파라미터 `permission` 이 주어지면, 현재 사용자가 해당 권한을 갖지 못한 경우 403 `{"error":{"message":...}}`. 에디터를 임베드하는 화면이 업로드 권한을 세분화할 때 사용한다.
- **인가**: 업로드 권한은 서버측 라우트 게이트(`auth:sanctum` + `admin`)만으로 판정한다. 클라이언트가 검사 대상 권한명을 지정하던 방식(query 파라미터 `permission`)은 제거되었다.
- 업로드 성공 시 반환하는 `url` 은 기본적으로 공개 서빙 엔드포인트(`GET /images/{hash}`)의 절대 URL 이다. 단, 공개 자산 디스크(플러그인 설정 `public_asset_disk` > 코어 전역 `drivers.public_asset_disk`)가 직접 URL 을 지원하는 디스크(public / URL 이 설정된 s3 등)로 지정되어 있으면 스트리밍 경로 대신 그 디스크의 직접 URL(CDN)이 반환된다. 디스크 미설정('none' 포함)·URL 미지원 디스크·`core.storage.filter_url` 훅 차단 시에는 기존 스트리밍 경로로 폴백한다.
**응답 예시** (성공 — CKEditor 규격, envelope 아님)
@@ -27,7 +27,6 @@
"placeholder": "{{extensionPointProps.placeholder ?? ''}}",
"readOnly": "{{_computed.isReadOnly ?? extensionPointProps.disabled ?? extensionPointProps.readOnly ?? false}}",
"imageUpload": "{{_global.plugins['sirsoft-ckeditor5']?.imageUpload ?? false}}",
"uploadPermission": "{{extensionPointProps.uploadPermission ?? ''}}",
"height": "{{_global.plugins['sirsoft-ckeditor5']?.editorHeight ?? 400}}",
"toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}"
}
@@ -1,3 +1,4 @@
// e2e:allow ⑫⑬ 보안 수정 — 클라이언트 지정 업로드 권한 쿼리(죽은 배선) 제거만 수행. 업로드 동작·UI 불변(서버 admin 게이트 유지), 사용자 시나리오 변화 없음.
/**
* CKEditor5 초기화 핸들러
*
@@ -65,7 +66,6 @@ interface InitEditorParams {
placeholder?: string;
readOnly?: boolean | string;
imageUpload?: boolean | string;
uploadPermission?: string;
height?: number | string;
toolbar?: string;
}
@@ -641,14 +641,10 @@ function getPlugins(CKEDITOR: CKEditorGlobal, toolbarType: string, withImageUplo
/**
* 이미지 업로드 URL을 생성합니다.
* uploadPermission이 있으면 쿼리스트링으로 추가합니다.
* 인가는 서버 라우트 게이트(auth:sanctum + admin)가 전담하므로 쿼리 파라미터는 붙이지 않습니다.
*/
function buildUploadUrl(uploadPermission: string): string {
const base = '/api/plugins/sirsoft-ckeditor5/upload';
if (uploadPermission) {
return `${base}?permission=${encodeURIComponent(uploadPermission)}`;
}
return base;
function buildUploadUrl(): string {
return '/api/plugins/sirsoft-ckeditor5/upload';
}
/**
@@ -673,7 +669,6 @@ async function createEditorInstance(
placeholder: string;
readOnly: boolean;
imageUpload: boolean;
uploadPermission: string;
height: number;
toolbar: string;
containerId: string;
@@ -764,7 +759,7 @@ async function createEditorInstance(
if (options.imageUpload) {
editorConfig.simpleUpload = {
uploadUrl: buildUploadUrl(options.uploadPermission),
uploadUrl: buildUploadUrl(),
headers: getUploadHeaders(),
};
}
@@ -1005,7 +1000,6 @@ export async function initEditorHandler(
const withImageUpload = params.imageUpload !== undefined
? (params.imageUpload === true || params.imageUpload === 'true')
: (pluginSettings.imageUpload === true || pluginSettings.imageUpload === 'true');
const uploadPermission = params.uploadPermission ?? '';
const placeholder = params.placeholder ?? '';
const height = params.height !== undefined ? (Number(params.height) || 400) : (Number(pluginSettings.editorHeight) || 400);
const toolbarType = (params.toolbar !== undefined ? (params.toolbar as string) : (pluginSettings.toolbar as string)) ?? 'standard';
@@ -1055,7 +1049,6 @@ export async function initEditorHandler(
placeholder,
readOnly: isReadOnly,
imageUpload: withImageUpload,
uploadPermission,
height,
toolbar: toolbarType,
containerId,
@@ -39,15 +39,9 @@ class ImageUploadController extends AdminBaseController
*/
public function upload(ImageUploadRequest $request): JsonResponse
{
$uploadPermission = $request->query('permission', '');
// 인가는 라우트 게이트(AdminBaseController: auth:sanctum + admin)가 전담한다.
// 클라이언트가 검사 권한명을 지정하던 쿼리 분기는 죽은 인가라 제거했다.
$user = $this->getCurrentUser();
if ($uploadPermission && $user && ! $user->hasPermission($uploadPermission)) {
// audit:allow response-helper-bypass reason: CDN 상위 SimpleUploadAdapter 가 최상위 error.message 를 읽는다 (봉투 불가)
return response()->json([
'error' => ['message' => __('sirsoft-ckeditor5::messages.upload.forbidden')],
], 403);
}
try {
$image = $this->imageUploadService->upload(
@@ -0,0 +1,113 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Http\Controllers;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
/**
* CKEditor5 이미지 업로드 인가 회귀 테스트 (결함 ⑫⑬)
*
* 인가는 라우트 게이트(AdminBaseController: auth:sanctum + admin)가 전담한다.
* 클라이언트 쿼리스트링(`?permission=`)이 검사 권한명을 정하고 빈 값이면 검사를
* 통째로 skip 하던 죽은 인가 분기가 제거되었음을 고정한다.
*
* POST /api/plugins/sirsoft-ckeditor5/upload
*/
class ImageUploadAuthTest extends PluginTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('plugins');
}
/**
* 비로그인 요청은 admin 게이트에서 401 로 거부된다.
*
* @scenario case=guest_upload
*
* @effects upload_requires_admin
*/
public function test_upload_rejects_guest(): void
{
$file = UploadedFile::fake()->image('test.jpg');
$response = $this->postJson('/api/plugins/sirsoft-ckeditor5/upload', [
'upload' => $file,
]);
$response->assertUnauthorized();
$this->assertDatabaseCount('ckeditor5_image_uploads', 0);
}
/**
* 인증된 일반 회원(비관리자)은 admin 게이트에서 403 으로 거부된다.
*
* @scenario case=non_admin_upload
*
* @effects upload_requires_admin
*/
public function test_upload_rejects_authenticated_non_admin(): void
{
$member = User::factory()->create();
$file = UploadedFile::fake()->image('test.jpg');
$response = $this->actingAs($member)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$response->assertForbidden();
$this->assertDatabaseCount('ckeditor5_image_uploads', 0);
}
/**
* 관리자는 정상적으로 업로드된다 (기본 동작 보존).
*
* @scenario case=admin_upload
*
* @effects upload_requires_admin
*/
public function test_upload_succeeds_for_admin(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->image('photo.jpg', 100, 100)->size(500);
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$response->assertStatus(201);
$response->assertJsonStructure(['url']);
$this->assertDatabaseCount('ckeditor5_image_uploads', 1);
}
/**
* 클라이언트가 임의의 `?permission=` 쿼리를 붙여도 응답은 불변이다.
*
* 과거 죽은 인가 분기에서는 관리자가 보유하지 않은 권한명을 넘기면 403 이 되었으나,
* 이제 쿼리는 완전히 무시되고 admin 게이트만 판정하므로 201 이 유지된다.
*
* @scenario case=permission_param_ignored
*
* @effects client_permission_param_has_no_effect
*/
public function test_upload_ignores_client_supplied_permission_query(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->image('photo.jpg', 100, 100)->size(500);
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload?permission=some.permission.the.admin.lacks',
['upload' => $file]
);
$response->assertStatus(201);
$response->assertJsonStructure(['url']);
$this->assertDatabaseCount('ckeditor5_image_uploads', 1);
}
}
@@ -0,0 +1,20 @@
feature: 에디터 이미지 업로드 인가 고정 (2026-08 보안 수정)
description: |
2026-08 비공개 보안 제보(⑫/⑬) 대응 — 업로드 엔드포인트가 클라이언트 쿼리스트링
(?permission=)으로 인가 판정 대상 권한명을 받던 죽은 분기를 제거. 인가는 서버 라우트
게이트(auth:sanctum + admin, AdminBaseController)가 전담하고, 클라이언트가 보내는
권한 파라미터는 어떤 값이든 판정에 영향을 주지 않는다. 프론트(initEditor.ts /
html-editor.json)의 uploadPermission 전달·조립 코드도 제거(dist 재빌드 동반).
axes:
case: [guest_upload, non_admin_upload, admin_upload, permission_param_ignored]
exclusions: []
effects:
- upload_requires_admin
- client_permission_param_has_no_effect
test_files:
- plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/ImageUploadAuthTest.php