@iasiv5/dsh-obmc-web 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -3
  2. package/lib/index.js +138 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @iasiv5/dsh-obmc-web
2
2
 
3
- 在 DSH GUI 里使用 **BMC Web UI**(OpenBMC / bmcweb,AST2700 EVB 实测)。
3
+ 在 DSH GUI 里使用 **BMC Web UI**(OpenBMC / bmcweb,AST2700 EVB 实测;
4
+ 插件本体于 DSH Web `0.1.7-rc.2` 实测)。
4
5
  host 半身在 DSH web 服务器的 GUI origin 上注册一组同源代理路由,经
5
6
  `obmc-web-relay.service`(SSH 本地转发,仅绑 `127.0.0.1`,端口默认
6
7
  18443、被占自动顺延)抵达 BMC;client 半身仿照 dsh-surf,在「设置」
@@ -104,7 +105,8 @@ BMC 地址常由跳板侧 DHCP 动态分配,可能变化。当隧道目标失
104
105
  报错,或 `/bmc-status?probe=1` 实测失败)时,插件自动 SSH 到跳板机定位
105
106
  bmcweb 实际地址:先查 dnsmasq 租约表 + ARP 表(单纯换址时亚秒级返回),
106
107
  不通再对候选网段做 TCP-443 扫描;候选地址以 `/redfish/v1` 应答做指纹
107
- 校验(200 或 401 都算 bmcweb 在场)。探测网段的确定顺序:配置的
108
+ 校验(200 需带 `"RedfishVersion"` 服务签名;401 保留原判;当选后的
109
+ TLS 证书另经 TOFU 钉扎,见「安全说明」)。探测网段的确定顺序:配置的
108
110
  `OBMC_SUBNET` → `BMC_TARGET` IP 的 /24 → 跳板机自身枚举的直连 IPv4
109
111
  网段。找到后写回 env 文件的 `BMC_TARGET` 行、经 user manager 重启
110
112
  relay,并通过隧道验证后才报告成功;若 relay unit 尚未安装(首次
@@ -126,7 +128,18 @@ bootstrap),地址仍会写入配置并提示下一步去点「安装并启
126
128
  是清空整个源的 Cookie 与存储——在同源代理下会连带清掉 GUI 自身的会话
127
129
  Cookie,把 DSH 一起登出;BMC 侧登出不受影响:显式的 `SESSION=` 过期
128
130
  Set-Cookie 照常透传,服务端会话照常销毁),CSP 仅移除
129
- `frame-ancestors` 指令;其余(含 Cookie)原样透传。
131
+ `frame-ancestors` 指令;其余响应头原样透传。
132
+ - 上行 Cookie 白名单(0.1.3 起):GUI 源的 Cookie 不再整串透传给
133
+ BMC——只有 bmcweb 自己的 `SESSION` / `XSRF-TOKEN` 允许跨隧道,其余
134
+ (尤其 DSH 会话 Cookie `__Host-dsh_auth_session`)在上行前剥离,
135
+ BMC 这个信任域永远收不到 DSH 会话。
136
+ - BMC TLS 证书 TOFU 钉扎(0.1.3 起):隧道对端的自签证书按 `BMC_TARGET`
137
+ 记录 SHA-256 指纹(`~/.config/systemd/user/obmc-relay-trust.json`,
138
+ `OBMC_WEB_TRUST` 可改址),首次见到即钉扎;此后指纹不符的交换会被
139
+ 502 拒绝、旧指纹作废、下一次请求重新钉扎——静默中间人最多拿到一次
140
+ 被拒的交换,且拿不到 Cookie。BMC 侧重生成证书同样自动重钉扎;如需
141
+ 彻底重置删除上述文件即可。WS(KVM/控制台)路径同受保护——顺带修复
142
+ 了该路径调用不存在的 `https.connect` 导致首个升级请求即崩的问题。
130
143
  - 非 `/login` 的上游 401 统一降级为 403:BMC SPA 的 axios 拦截器对其他
131
144
  401 会执行 `window.location = "/login"`——根绝对路径的顶层导航,在
132
145
  同源代理下会逃出 `/bmc` 前缀,落到 GUI 自己的 `/login` 并被认证门
package/lib/index.js CHANGED
@@ -52,6 +52,7 @@
52
52
  */
53
53
  import https from 'node:https'
54
54
  import net from 'node:net'
55
+ import tls from 'node:tls'
55
56
  import { execFile, spawn } from 'node:child_process'
56
57
  import fs from 'node:fs'
57
58
  import os from 'node:os'
@@ -339,7 +340,14 @@ for f in $lease_files; do
339
340
  done
340
341
  cands="$cands $(ip neigh show 2>/dev/null | awk '{print $1}' | grep -E '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)' | head -100)"
341
342
  cands=$(printf '%s\\n' $cands | sort -u)
342
- check() { code=$(curl -k -m 3 -s -o /dev/null -w '%{http_code}' "https://$1/redfish/v1" 2>/dev/null); [ "$code" = 200 ] || [ "$code" = 401 ]; }
343
+ check() {
344
+ resp=$(curl -k -m 3 -s -w '\\n%{http_code}' "https://$1/redfish/v1" 2>/dev/null)
345
+ code=\${resp##*$'\\n'}
346
+ body=\${resp%$'\\n'*}
347
+ [ "\$code" = 200 ] && printf '%s' "\$body" | grep -q '"RedfishVersion"' && return 0
348
+ [ "\$code" = 401 ] && return 0
349
+ return 1
350
+ }
343
351
  for c in $cands; do
344
352
  if check "$c"; then echo "BMC_IP=$c"; exit 0; fi
345
353
  done
@@ -505,17 +513,124 @@ function cspWithoutFrameAncestors(value) {
505
513
  .join('; ')
506
514
  }
507
515
 
516
+ // ── upstream trust domain hardening (audit 2026-09-28) ───────────────────
517
+ // The relay tunnel crosses into the BMC's trust domain. Two exposures are
518
+ // closed here:
519
+ // 1. Cookie bleed: the GUI origin's cookies (notably the DSH session cookie
520
+ // __Host-dsh_auth_session) used to ride every proxied request to the
521
+ // BMC. Only bmcweb's own cookie names may cross now.
522
+ // 2. TLS pinning (TOFU): the tunnel TLS terminates at the BMC's self-signed
523
+ // certificate. The first certificate seen for a BMC target is pinned to
524
+ // a trust file; a later mismatch is refused once, the stale pin dropped,
525
+ // and the next request re-pins — a silent MITM gets one refused exchange,
526
+ // never the session. Regenerated BMC certs re-enroll the same way.
527
+
528
+ const TRUST_FILE = process.env.OBMC_WEB_TRUST
529
+ ?? path.join(os.homedir(), '.config/systemd/user/obmc-relay-trust.json')
530
+ const UPSTREAM_COOKIE_ALLOW = new Set(['session', 'xsrf-token'])
531
+
532
+ /** Keep only bmcweb's own cookie names; everything else never crosses. */
533
+ export function filterUpstreamCookies(cookieHeader) {
534
+ if (cookieHeader === undefined || cookieHeader === null) return undefined
535
+ const kept = String(cookieHeader)
536
+ .split(';')
537
+ .map(part => part.trim())
538
+ .filter(part => {
539
+ if (part === '') return false
540
+ const eq = part.indexOf('=')
541
+ const name = (eq === -1 ? part : part.slice(0, eq)).trim().toLowerCase()
542
+ return UPSTREAM_COOKIE_ALLOW.has(name)
543
+ })
544
+ return kept.length === 0 ? undefined : kept.join('; ')
545
+ }
546
+
547
+ function loadTrustPins(file = TRUST_FILE) {
548
+ try {
549
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
550
+ if (parsed && typeof parsed === 'object' && parsed.pins && typeof parsed.pins === 'object') {
551
+ return parsed
552
+ }
553
+ } catch { /* missing/corrupt → treat as empty (first use re-pins) */ }
554
+ return { pins: {} }
555
+ }
556
+
557
+ function writeTrustStore(store, file) {
558
+ fs.mkdirSync(path.dirname(file), { recursive: true })
559
+ const tmp = `${file}.tmp-${process.pid}`
560
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n')
561
+ fs.renameSync(tmp, file)
562
+ }
563
+
564
+ export function saveTrustPin(target, fingerprint256, file = TRUST_FILE) {
565
+ const store = loadTrustPins(file)
566
+ store.pins[target] = fingerprint256
567
+ store.updatedAt = new Date().toISOString()
568
+ writeTrustStore(store, file)
569
+ }
570
+
571
+ function clearTrustPin(target, file = TRUST_FILE) {
572
+ const store = loadTrustPins(file)
573
+ if (store.pins[target] === undefined) return
574
+ delete store.pins[target]
575
+ store.updatedAt = new Date().toISOString()
576
+ writeTrustStore(store, file)
577
+ }
578
+
579
+ /**
580
+ * Build a checkServerIdentity-style validator for the tunnel TLS. TOFU:
581
+ * the first certificate seen for the configured BMC target is pinned; a
582
+ * later mismatch is refused once and the stale pin dropped (the next
583
+ * request re-enrolls). target=null (no BMC_TARGET in env) disables pinning.
584
+ */
585
+ export function makeTlsPinChecker(target, file = TRUST_FILE) {
586
+ if (!target) return undefined
587
+ return (_host, cert) => {
588
+ const fp = cert?.fingerprint256
589
+ if (typeof fp !== 'string' || fp === '') return // nothing to pin on; do not fail the exchange
590
+ const pins = loadTrustPins(file).pins
591
+ const known = pins[target]
592
+ if (known === undefined) {
593
+ saveTrustPin(target, fp, file)
594
+ return
595
+ }
596
+ if (known !== fp) {
597
+ clearTrustPin(target, file)
598
+ throw new Error(`BMC TLS certificate for ${target} changed (TOFU pin mismatch) — pin dropped; next request re-enrolls`)
599
+ }
600
+ }
601
+ }
602
+
508
603
  /** Forward one plain HTTP exchange to the BMC tunnel. */
509
604
  function proxyRequest(req, res, targetPath, log) {
510
605
  const headers = {}
511
606
  for (const [key, value] of Object.entries(req.headers)) {
512
- if (HOP.has(key.toLowerCase()) || key.toLowerCase() === 'host') continue
607
+ const lower = key.toLowerCase()
608
+ if (HOP.has(lower) || lower === 'host' || lower === 'cookie') continue
513
609
  headers[key] = value
514
610
  }
515
611
  headers.host = `${UP_HOST}:${envConfig.relayPort}`
612
+ const cookie = filterUpstreamCookies(req.headers.cookie)
613
+ if (cookie !== undefined) headers.cookie = cookie
516
614
  const upstream = https.request(
517
- { host: UP_HOST, port: envConfig.relayPort, method: req.method, path: targetPath, headers, agent },
615
+ {
616
+ host: UP_HOST, port: envConfig.relayPort, method: req.method, path: targetPath, headers, agent,
617
+ },
518
618
  (up) => {
619
+ // TOFU pin enforcement: with rejectUnauthorized:false Node skips
620
+ // checkServerIdentity entirely, so the pin is verified manually per
621
+ // exchange against the TLS socket's peer certificate (audit 2026-09-28).
622
+ const fp = up.socket?.getPeerCertificate?.(true)?.fingerprint256
623
+ try {
624
+ makeTlsPinChecker(envConfig.bmcTarget)?.(null, { fingerprint256: fp })
625
+ } catch (err) {
626
+ log?.warn?.(`[dsh-obmc-web] ${err.message}`)
627
+ up.destroy()
628
+ try {
629
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
630
+ res.end('dsh-obmc-web: BMC TLS certificate changed (TOFU pin mismatch) — pin dropped; the next request re-enrolls')
631
+ } catch { /* socket already gone */ }
632
+ return
633
+ }
519
634
  const raw = []
520
635
  for (let i = 0; i < up.rawHeaders.length; i += 2) {
521
636
  const key = up.rawHeaders[i]
@@ -564,11 +679,29 @@ function proxyRequest(req, res, targetPath, log) {
564
679
  function proxyUpgrade(req, socket, head, log) {
565
680
  const headers = {}
566
681
  for (const [key, value] of Object.entries(req.headers)) {
567
- if (key.toLowerCase() === 'host') continue
682
+ const lower = key.toLowerCase()
683
+ if (lower === 'host' || lower === 'cookie') continue
568
684
  headers[key] = value
569
685
  }
570
686
  headers.host = `${UP_HOST}:${envConfig.relayPort}`
571
- const upstream = https.connect({ host: UP_HOST, port: envConfig.relayPort, rejectUnauthorized: false }, () => {
687
+ const cookie = filterUpstreamCookies(req.headers.cookie)
688
+ if (cookie !== undefined) headers.cookie = cookie
689
+ // NOTE: https.connect does not exist in Node — the WS path used to crash
690
+ // with a TypeError on the first upgrade request. The tunnel is a plain
691
+ // TLS socket to the relay; tls.connect is the correct primitive, and the
692
+ // secureConnect callback is where the TOFU pin now runs (first byte).
693
+ const upstream = tls.connect({ host: UP_HOST, port: envConfig.relayPort, rejectUnauthorized: false }, () => {
694
+ const fp = typeof upstream.getPeerCertificate === 'function'
695
+ ? upstream.getPeerCertificate(true)?.fingerprint256
696
+ : undefined
697
+ try {
698
+ makeTlsPinChecker(envConfig.bmcTarget)?.(null, { fingerprint256: fp })
699
+ } catch (err) {
700
+ log?.warn?.(`[dsh-obmc-web] ${err.message}`)
701
+ socket.destroy()
702
+ upstream.destroy()
703
+ return
704
+ }
572
705
  const lines = [`${req.method} ${req.url} HTTP/1.1`]
573
706
  for (const [key, value] of Object.entries(headers)) lines.push(`${key}: ${value}`)
574
707
  upstream.write(lines.join('\r\n') + '\r\n\r\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-obmc-web",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "OpenBMC 控制台入口:设置一级菜单「BMC 控制台」面板一键在顶层标签页打开 BMC Web UI,同源代理经 SSH 本地转发 relay(obmc-web-relay)抵达 BMC,BMC IP 变化自动探测并重定向隧道;需 SSH 跳板机可达 BMC。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",