
Docmost가 첨부 노드 내의 javascript: URL을 수락하고, 저장 및 렌더링 과정을 통해 이를 보존한 후, Docmost 오리진 내에서 클릭 가능한 앵커로 다시 변환했습니다.
Docmost는 attachment 노드 내부의 javascript: URL을 허용했으며, 이를 저장 및 렌더링 과정에서 보존한 후 Docmost 오리진에서 클릭 가능한 앵커로 다시 변환했습니다.
저는 Docmost(오픈소스 협업 문서 플랫폼)에서 높은 심각도의 저장형 XSS 문제를 식별하고, 책임감 있게 공개하며 재현했습니다.
Docmost 공식 사이트는 이 플랫폼을 엔터프라이즈급 온프레미스 위키로 소개하며 300만 회 이상 다운로드되었다고 밝히고 있으며, 빌니우스 시, Bechtle, 호주 정부, 적십자사, ETS 퀘벡 등 다양한 조직의 팀이 신뢰한다고 합니다.
이 버그는 리치 텍스트 시스템에서 놓치기 쉬운 곳에 있었습니다.
일반적인 링크 확장이 아니라, 파일 첨부에 사용되는 별도의 커스텀 노드 타입에 있었습니다.
저는 매우 구체적인 질문을 가지고 에디터 파이프라인을 검토하고 있었습니다.
일반 링크가 javascript: URL을 차단한다면, attachment 노드는 앵커 싱크에 도달하기 전에 동일한 규칙을 적용할까?
취약한 버전에서는 적용되지 않았습니다.
Docmost는 페이지 JSON 내의 악의적인 attachment 노드를 수락하고, url 속성을 변경하지 않은 채 저장한 후, 나중에 그 값을 클릭 가능한 <a href="javascript:..."> 요소로 다시 렌더링했습니다.
이 문제가 CVE-2026-34212가 되었습니다.
Docmost: docmost/docmost
권고: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
패치 버전: v0.71.0
공격자가 제어하는 attachment 노드 URL -> 페이지 JSON이 수락 및 변경 없이 저장됨 -> HTML/React 렌더링이 해당 URL을 앵커 href로 변환 -> 피해자가 attachment 동작 클릭 -> 공격자가 제어하는 JavaScript가 Docmost 오리진에서 실행됨
Docmost는 페이지 콘텐츠를 ProseMirror/Tiptap 호환 JSON 형식으로 저장합니다.
해당 콘텐츠 모델에는 다음과 같은 항목을 위한 커스텀 블록 노드가 포함됩니다.
attachment 노드는 다음과 같은 필드를 저장합니다.
urlnamemimesizeattachmentId서버는 페이지 콘텐츠를 여러 형식으로 수락합니다.
jsonmarkdownhtml그리고 저장하기 전에 ProseMirror JSON으로 정규화합니다.
즉, URL을 전달할 수 있는 모든 노드 타입은 직접적인 신뢰 경계의 일부입니다.
해당 노드 타입 중 하나가 결국 <a href>로 렌더링된다면 URL 스킴 처리는 선택 사항이 아닙니다.
이는 보안 모델의 일부입니다.
커스텀 에디터 확장은 보안 불일치의 빈번한 원인입니다.
기본 시스템은 이미 위험한 URL을 올바르게 처리하는 방법을 알고 있을 수 있지만, 각 커스텀 노드는 자체 싱크에서 동일한 규칙을 다시 적용해야 합니다.
이는 예측 가능한 검토 전략을 만듭니다.
이것이 바로 이 버그를 발견하게 한 방법입니다.
Docmost의 일반적인 링크 확장은 이미 javascript:를 위험한 것으로 처리했습니다.
attachment 노드는 그렇지 않았습니다.
이 비대칭성을 보면 보안 질문이 분명해집니다.
url이 javascript:인 attachment 노드를 유지하고, 그것이 다시 라이브 앵커로 렌더링되도록 할 수 있을까?
답은 '예'였습니다.
근본 원인은 콘텐츠 노드 타입 간의 일관되지 않은 URL 정제였습니다.
서버 측 콘텐츠 경로는 전체 콘텐츠가 ProseMirror 스키마와 일치하기만 하면 임의의 attachment URL을 수락했습니다.
취약한 버전에서:
CreatePageDto는 content?: string | object를 수락했습니다.PageService.parseProsemirrorContent()는 markdown, html, 또는 json을 정규화했습니다.jsonToNode(prosemirrorJson)을 호출했습니다.이 검증 단계는 구조적 유효성만 확인했고 URL 안전성은 확인하지 않았습니다.
취약한 서버 로직의 핵심 부분은 사실상 다음과 같았습니다.
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
여기서는 attachment URL 스킴 정규화가 전혀 이루어지지 않았습니다.
이후 attachment 확장은 공격자가 제어하는 값을 직접 렌더링했습니다.
취약한 attachment 노드는 이렇게 했습니다.
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
그리고 나서:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
클라이언트 측에서는 React 노드 뷰가 이것을 다시 다음과 같이 감쌌습니다.
<a href={getFileUrl(url)} target="_blank">
하지만 getFileUrl()은 다음만 특별 처리했습니다.
http URL/api/.../files/...그 외의 모든 것은 변경되지 않은 채 반환되었습니다.
따라서 다음과 같은 페이로드가:
javascript:alert(document.domain)
다음을 통과했습니다.
이것만으로도 저장형 XSS에 충분합니다.
근본 원인을 특히 명확하게 만드는 것은 비교 지점입니다.
Docmost의 일반적인 링크 확장은 javascript:를 명시적으로 차단했습니다.
parseHTML()에서 javascript:를 거부했습니다.renderHTML()에서 javascript: href를 공백으로 만들었습니다.따라서 제품은 이미 이 스킴이 위험하다는 것을 알고 있었습니다.
attachment 노드는 단순히 동일한 정책을 적용하지 않았습니다.
이것이 "에디터 내 일반 XSS"가 아닌 이유입니다.
이는 노드별 신뢰 경계 간극이었습니다.
이 버그는 단순히 안전하지 않은 HTML의 미학에 관한 것이 아니었습니다.
페이지를 편집할 수 있는 공격자가 악성 페이로드를 유지하여, 나중에 다른 사용자가 렌더링된 attachment와 상호작용할 때 Docmost 오리진에서 실행되도록 할 수 있었습니다.
이는 오리진 내 스크립트가 다음과 같은 작업을 할 수 있기 때문에 중요합니다.
클릭이 필요하다고 해서 이것이 사소한 문제로 축소되지는 않습니다.
클릭은 정상적인 제품 동작의 일부입니다. UI는 의도적으로 attachment를 실행 가능한 링크/아이콘으로 제시합니다.
따라서 보안 질문은 "공격자가 어떤 상호작용 없이도 임의의 JS를 강제할 수 있는가?"가 아닙니다.
진짜 질문은:
애플리케이션이 공격자가 제어하는 스크립트를 포함한 콘텐츠를 저장하고, 나중에 다른 사용자에게 신뢰할 수 있는 상호작용 경로로 다시 제시하는가?
취약한 버전에서는 그랬습니다.
그것이 저장형 XSS입니다.
악용 경로는 간단했습니다.
이로 인해 높은 권한을 가진 사용자도 현실적인 표적이 되었습니다.
워크스페이스 소유자, 관리자 또는 폭넓게 신뢰받는 편집자가 공격자가 제어하는 콘텐츠를 보고 attachment 동작을 클릭하면, 공격자의 스크립트는 더 높은 권한의 세션 컨텍스트에서 실행됩니다.
이것이 중요한 실용적 요점입니다.
공격자의 권한 요구 사항은 낮은 수준이었습니다. 피해자의 권한 수준이 XSS 세션이 얼마나 가치 있는지를 결정했습니다.
저는 Docmost v0.70.3 에 대해 실시간으로 문제를 검증했습니다.
PoC는 일반 HTTP 요청과 애플리케이션 자체 페이지 API만 사용했습니다.
흐름은 다음과 같습니다.
POST /api/pages/update를 format: "json"으로 보내고, url이 javascript: 페이로드인 attachment 노드를 포함시킵니다.POST /api/pages/info를 통해 페이지를 다시 요청합니다.href가 여전히 javascript:...인 앵커를 반환하는지 확인합니다.최소 악성 콘텐츠는 다음과 같습니다.
{
"pageId": "<pageId>",
"content": {
"type": "doc",
"content": [
{
"type": "attachment",
"attrs": {
"url": "javascript:alert(document.domain)",
"name": "policy.pdf",
"mime": "application/pdf",
"size": 1
}
}
]
},
"operation": "replace",
"format": "json"
}
테스트에서 관찰된 실제 결과는 다음과 같습니다.
019d18cf-4212-70b0-894a-fe20080fb0f1였습니다.POST /api/pages/info는 저장된 JSON을 반환했으며, 여기에는:"url": "javascript:alert(document.domain)"
format: "html"을 사용한 POST /api/pages/info는 다음을 포함하는 HTML을 반환했습니다.<div data-type="attachment" data-attachment-url="javascript:alert(document.domain)" data-attachment-name="policy.pdf" data-attachment-mime="application/pdf" data-attachment-size="1"><a href="javascript:alert(document.domain)" class="attachment" target="blank">policy.pdf</a></div>
이 HTML 응답이 중요한 증거입니다.
저는 "브라우저가 흥미로운 일을 할 수도 있다"는 모호한 주장에 의존할 필요가 없었습니다.
애플리케이션 자체가 정확한 실행 가능 싱크를 렌더링했습니다.
사용자가 해당 attachment 링크/아이콘을 클릭하면 브라우저는 javascript: URL을 생성한 페이지의 오리진에서 실행합니다.
에디터 기반 XSS의 경우 스크린샷만으로는 약한 증거입니다.
증상을 보여줄 뿐 경계 실패를 보여주지는 않습니다.
그렇기 때문에 PoC를 두 가지 명시적인 체크포인트로 구성했습니다.
저장 증명은 서버가 위험한 스킴을 수락하고 보존했음을 보여주었습니다.
렌더링된 싱크 증명은 애플리케이션이 저장된 값을 다시:
<a href="javascript:...">
로 변환했음을 보여주었습니다.
이 구분이 중요합니다.
제품이 위험한 입력을 저장하지만 모든 싱크 전에 무력화한다면, 강화 간격은 있을 수 있지만 반드시 활성 XSS는 아닙니다.
제품이 위험한 입력을 저장하고 나중에 실제 실행 싱크로 렌더링한다면, 전체 취약점 체인이 있는 것입니다.
이것이 바로 여기서 발생한 일입니다.
수정은 v0.71.0 에서 제공되었으며, attachment URL에 URL 정제를 적용하여 렌더링된 익스플로잇 경로를 해결했습니다.
이제 attachment 확장은 sanitizeUrl을 가져와서 사용하며, 다음을 포함합니다.
data-attachment-url 정제data-attachment-url 정제href 정제개념적으로 패치는 attachment 노드를 다음과 같이 변경했습니다.
클라이언트 측 헬퍼 getFileUrl()도 알 수 없는 스킴이 더 이상 그대로 통과되지 않도록 업데이트되었습니다.
패치된 버전에서 폴백 경로는 src를 그대로 반환하는 대신 sanitizeUrl(src)를 반환합니다.
이는 취약한 설계에 두 가지 상호 강화 문제가 있었기 때문에 수정의 중요한 부분입니다.
href를 렌더링했습니다.패치는 두 가정을 모두 제거했습니다.
이는 attachment URL 처리를 에디터의 나머지 보안 모델과 다시 일치시켰기 때문에 활성 XSS 경로에 대한 좋은 수정이었습니다.
하지만 여전히 더 넓은 강화 교훈이 있습니다.
클라이언트 측 또는 렌더링 시간 정제는 여기서 필요하지만, 페이지 생성/업데이트 중 서버 측에서 위험한 스킴을 거부하는 것이 훨씬 더 강력한 불변식이 될 것입니다.
가장 안전한 장기 모델은 다음과 같습니다.
리치 콘텐츠 시스템에서는 심층 방어가 중요합니다.
장기적인 적용 범위를 위해 가장 중요한 사례는 다음과 같습니다.
attachment.attrs.url = "javascript:..."를 포함한 JSON 페이지 업데이트data-attachment-url="javascript:..."를 포함한 HTML 가져오기href="javascript:..."를 절대 내보내지 않아야 함/api/files/... 및 /files/...와 같은 안전한 내부 attachment 경로는 계속 정상 작동해야 함핵심은 일관성입니다.
일반 링크는 정제되지만 커스텀 URL 보유 노드는 정제되지 않는다면, 에디터는 실제로 단일 URL 보안 정책을 가지지 않습니다.
단편만 가지며, 단편이 XSS 버그가 사는 곳입니다.
게시된 권고는 이 문제를 다음과 같이 분류했습니다.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
이는 7.6 / 높음에 해당합니다.
이는 방어 가능한 분류입니다.
중요한 속성은 다음과 같습니다.
사용자 상호작용은 여전히 필요합니다. 피해자가 attachment 링크/아이콘을 활성화해야 하기 때문입니다.
이것이 UI:R이 올바른 이유입니다.
하지만 일단 상호작용이 발생하면 보안 경계는 훨씬 더 일찍 실패한 것입니다. 애플리케이션이 위험한 스킴을 저장하고 실행 싱크로 다시 렌더링한 것입니다.
저는 GitHub Security Advisories를 통해 다음 항목과 함께 비공개로 보고했습니다.
해당 문제는 수락되었고, CVE-2026-34212가 할당되었으며, 2026년 4월 14일에 게시되었습니다.
공개 권고에는 현재 다음이 나열되어 있습니다.
0.70.30.71.0제 라이브 검증은 v0.70.3에서 수행되었으며, 이는 게시된 취약한 버전과 일치합니다.
여기서 얻는 주요 교훈은 단순히 "URL 정제"가 아닙니다.
모든 사람이 이미 그것을 알고 있습니다.
더 흥미로운 교훈은 다음과 같습니다.
애플리케이션에 하나의 안전한 URL 보유 노드 타입과 하나의 안전하지 않은 URL 보유 노드 타입이 있다면, 안전하지 않은 것이 실제 정책입니다.
리치 텍스트 시스템은 보안 검토보다 더 빠르게 커스텀 확장을 축적하는 경우가 많습니다.
이는 정확히 이런 종류의 비대칭성을 만듭니다.
이 버그는 또한 스키마 검증만으로는 충분하지 않다는 것을 보여줍니다.
jsonToNode()는 콘텐츠가 구조적으로 유효한 ProseMirror 데이터인지 확인했습니다.
렌더링하기에 안전한 콘텐츠인지 증명하지 않았습니다.
이는 다른 질문입니다.
보안 검토는 이러한 질문을 분리할 때 훨씬 더 명확해집니다.
attachment 노드는 첫 번째 질문을 통과했고 세 번째 질문에서 실패했습니다.
그것이 저장된 콘텐츠 버그가 그렇지 않으면 잘 구조화된 에디터 파이프라인 내에서 어떻게 생존하는지입니다.
data-attachment-url과 앵커 href를 공격자가 제어하는 입력에서 직접 렌더링했습니다.getFileUrl()은 알 수 없는 스킴을 변경 없이 반환했습니다.javascript:를 차단했지만, attachment 노드는 차단하지 않았습니다.v0.71.0의 수정은 attachment 노드와 클라이언트 폴백 경로에 sanitizeUrl 처리를 추가했습니다.이 취약점은 브라우저의 특이점에 관한 것이 아닙니다.
이는 애플리케이션 자체의 URL 안전 가정을 우회한 커스텀 콘텐츠 노드에 관한 것입니다.
Docmost는 공격자가 제어하는 attachment URL을 수락하고, 저장을 통해 보존한 후, 이를 애플리케이션 오리진 내의 라이브 앵커로 다시 렌더링했습니다.
그것이 CVE-2026-34212가 된 이유입니다.
v0.71.0의 패치는 활성 XSS 경로를 깔끔하게 차단했지만, 더 넓은 교훈은 유지할 가치가 있습니다.
에디터가 많은 애플리케이션에서는 URL을 전달할 수 있는 모든 커스텀 노드가 그 자체로 하나의 보안 경계이며, 그렇게 검토되어야 합니다.