dsh-mcloud 0.3.0 → 0.3.1

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * dsh-mcloud — 请求闸(Host / Origin / DNS rebinding 防护)
3
+ *
4
+ * 背景:`/api/dsh-mcloud/*` 挂在 DSH 的 webServer 上,而 webServer 本身**不做**
5
+ * Host / Origin 准入 —— 它只负责按路径分发。于是只要把一个网页开在你自己的
6
+ * 浏览器里,那个网页就能往 `http://127.0.0.1:<port>/api/dsh-mcloud/mkdir` 发请求,
7
+ * 带着你已登录的云盘凭据干活。
8
+ *
9
+ * 光靠 `x-dsh-mcloud: 1` 挡不住:静态请求头谁都能加,而且它不参与任何校验。
10
+ * 真正的边界是浏览器会替我们带、而攻击者**伪造不了**的两样东西:
11
+ *
12
+ * · Host —— 页面在 evil.example 上时,浏览器发的 Host 就是 evil.example。
13
+ * DNS rebinding 只能把 socket 拨到 127.0.0.1,改不了 Host。
14
+ * · Origin —— 跨站写请求必带;沙箱 iframe / file: 页面会给 "null"。
15
+ *
16
+ * 另外 `Sec-Fetch-Site: cross-site` 是现代浏览器的自述,见到就直接拒,
17
+ * 不给任何绕过解析差异的机会。这一层不建立身份,只回答「这个请求是不是
18
+ * 本部署自己的页面发出的」。
19
+ */
20
+
21
+ /**
22
+ * 只认「连进来的 socket 是环回」。挡得住局域网里的别人,挡不住你自己浏览器
23
+ * 里开着的网页 —— 那种请求的 remoteAddress 一样是 127.0.0.1。
24
+ * 所以它是第一道闸,不是唯一一道。
25
+ */
26
+ export function isLoopbackAddress(address) {
27
+ const text = String(address ?? '')
28
+ return text === '127.0.0.1'
29
+ || text === '::1'
30
+ || text === '::ffff:127.0.0.1'
31
+ || /^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/i.test(text)
32
+ }
33
+
34
+ /** authority 文本里不该出现的东西:空白、路径、凭据分隔符 */
35
+ const AUTHORITY_SHAPE = /^[^\s/\\?#@]+$/
36
+
37
+ /**
38
+ * 把 `host[:port]` 解析成 URL。
39
+ * 用 URL 解析而不是手写正则,是为了让默认端口、大小写、IPv6 方括号的归一化
40
+ * 跟浏览器完全一致 —— 自己写一套往往会和浏览器差一个边界。
41
+ */
42
+ export function parseAuthority(text, scheme = 'http:') {
43
+ if (typeof text !== 'string') return undefined
44
+ const value = text.trim()
45
+ if (!value || !AUTHORITY_SHAPE.test(value)) return undefined
46
+ try {
47
+ const url = new URL(`${scheme}//${value}`)
48
+ // 只接受裸 authority;带 userinfo 的一律当畸形
49
+ if (url.username || url.password) return undefined
50
+ return url
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** IPv6 的 URL.hostname 带方括号,比较前去掉 */
57
+ export function bareHostname(hostname) {
58
+ const text = String(hostname ?? '').trim().toLowerCase()
59
+ if (text.startsWith('[') && text.endsWith(']')) return text.slice(1, -1)
60
+ return text
61
+ }
62
+
63
+ /** 环回主机名:localhost / *.localhost / 127.0.0.0/8 / ::1 */
64
+ export function isLoopbackHostname(hostname) {
65
+ const name = bareHostname(hostname)
66
+ if (!name) return false
67
+ if (name === 'localhost' || name.endsWith('.localhost')) return true
68
+ if (name === '::1' || name === '0:0:0:0:0:0:0:1') return true
69
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)
70
+ }
71
+
72
+ /** URL 没写端口时,按协议取默认端口 */
73
+ export function effectivePort(url, protocol) {
74
+ if (url.port) return url.port
75
+ return protocol === 'https:' ? '443' : '80'
76
+ }
77
+
78
+ function headerValue(headers, name) {
79
+ const value = headers?.[name]
80
+ if (typeof value === 'string') return value
81
+ if (Array.isArray(value)) return value[0]
82
+ return undefined
83
+ }
84
+
85
+ /**
86
+ * 判断一条请求能不能进插件路由。
87
+ *
88
+ * @param req - node:http 的 IncomingMessage(只用到 socket.remoteAddress 与 headers)
89
+ * @param options.webServer - 可选;提供 host / port / protocol 用来核对 Host 的端口。
90
+ * 这是个 getter 友好的对象:端口要等 listen 之后才知道,调用方应当现读。
91
+ * @param options.allowedAuthorities - 额外的 `host` 或 `host:port` 白名单,
92
+ * 给「前面挂了反代 / 用了自定义域名」的部署留的口子。不带端口的条目匹配任意端口。
93
+ * @returns 拒绝理由;通过时返回 null。
94
+ */
95
+ export function requestRejection(req, options = {}) {
96
+ if (!isLoopbackAddress(req?.socket?.remoteAddress)) return 'forbidden: loopback-only'
97
+
98
+ const server = options.webServer
99
+ const protocol = server?.protocol === 'https:' ? 'https:' : 'http:'
100
+
101
+ const hostUrl = parseAuthority(headerValue(req?.headers, 'host'), protocol)
102
+ if (hostUrl === undefined) return 'forbidden: missing or malformed Host'
103
+
104
+ const hostname = bareHostname(hostUrl.hostname)
105
+ if (!hostname) return 'forbidden: malformed Host'
106
+
107
+ const bindHostname = typeof server?.host === 'string' ? bareHostname(server.host) : ''
108
+ const listenerPort = typeof server?.port === 'number' ? String(server.port) : undefined
109
+
110
+ const sameBindAddress = bindHostname !== '' && hostname === bindHostname
111
+ const listed = (options.allowedAuthorities ?? []).some((entry) => {
112
+ const entryUrl = parseAuthority(entry, protocol)
113
+ if (entryUrl === undefined) return false
114
+ if (bareHostname(entryUrl.hostname) !== hostname) return false
115
+ // 不带端口的条目按「任意端口」算,跟 DSH 自己的 trustedHosts 语义一致
116
+ if (!entryUrl.port) return true
117
+ return effectivePort(entryUrl, protocol) === effectivePort(hostUrl, protocol)
118
+ })
119
+
120
+ if (!isLoopbackHostname(hostname) && !sameBindAddress && !listed) {
121
+ return 'forbidden: Host is not this deployment'
122
+ }
123
+
124
+ // 端口必须对上:否则 evil.example:80 这条 authority 在 rebinding 之后
125
+ // 也能打到本监听器上,而 Host 那一关已经过了。
126
+ if (listenerPort !== undefined && effectivePort(hostUrl, protocol) !== listenerPort) {
127
+ const loose = isLoopbackHostname(hostname) || sameBindAddress
128
+ if (loose) return 'forbidden: Host port is not this listener'
129
+ }
130
+
131
+ if (headerValue(req?.headers, 'sec-fetch-site') === 'cross-site') {
132
+ return 'forbidden: cross-site request'
133
+ }
134
+
135
+ const origin = headerValue(req?.headers, 'origin')
136
+ if (origin !== undefined) {
137
+ // 沙箱 iframe、file: 页面、某些重定向会把 Origin 写成字面量 "null"
138
+ if (String(origin).trim().toLowerCase() === 'null') return 'forbidden: opaque Origin'
139
+ let originUrl
140
+ try {
141
+ originUrl = new URL(origin)
142
+ } catch {
143
+ return 'forbidden: malformed Origin'
144
+ }
145
+ if (originUrl.protocol !== 'http:' && originUrl.protocol !== 'https:') {
146
+ return 'forbidden: opaque Origin'
147
+ }
148
+ if (originUrl.host.toLowerCase() !== hostUrl.host.toLowerCase()) {
149
+ return 'forbidden: cross-origin request'
150
+ }
151
+ }
152
+
153
+ return null
154
+ }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
- {
1
+ {
2
2
  "name": "dsh-mcloud",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "中国移动云盘(139)接入 DeepSeek Harness:容量查询、列目录、上传下载、转移、以及在会话页提供一个可操作的标签面板。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "scripts": {
8
+ "test": "node --test test/request-guard.test.js test/routes.test.js"
9
+ },
7
10
  "icon": "./icon.svg",
8
11
  "exports": {
9
12
  ".": "./lib/index.js",