@linxin666/dsh-ssh 0.1.17 → 0.1.19
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.
- package/lib/client.js +14 -11
- package/lib/client.js.map +1 -1
- package/lib/index.js +50 -27
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/engine/connection-pool.d.ts +1 -1
- package/lib/types/loopback.d.ts +24 -0
- package/package.json +1 -1
- package/src/client/locales.ts +2 -2
- package/src/client/panel/SshPanel.tsx +9 -1
- package/src/client/panel/panel.module.css +6 -0
- package/src/engine/connection-pool.ts +5 -5
- package/src/loopback.ts +63 -0
- package/src/routes.ts +1 -23
package/lib/index.js
CHANGED
|
@@ -1328,13 +1328,13 @@ const DEFAULTS = {
|
|
|
1328
1328
|
sftpConcurrency: 8
|
|
1329
1329
|
};
|
|
1330
1330
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
1331
|
-
function buildConnectConfig(entry, sock) {
|
|
1331
|
+
function buildConnectConfig(entry, sock, opts) {
|
|
1332
1332
|
const config = {
|
|
1333
1333
|
host: entry.host,
|
|
1334
1334
|
port: entry.port,
|
|
1335
1335
|
username: entry.user,
|
|
1336
|
-
readyTimeout:
|
|
1337
|
-
keepaliveInterval:
|
|
1336
|
+
readyTimeout: opts.connectTimeoutMs,
|
|
1337
|
+
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
1338
1338
|
keepaliveCountMax: 3
|
|
1339
1339
|
};
|
|
1340
1340
|
if (sock !== void 0) config.sock = sock;
|
|
@@ -1401,7 +1401,7 @@ async function connectChain(engine, entry) {
|
|
|
1401
1401
|
for (const client of hops) client.end();
|
|
1402
1402
|
throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
|
|
1403
1403
|
}
|
|
1404
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock));
|
|
1404
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
|
|
1405
1405
|
hops.push(hopClient);
|
|
1406
1406
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
|
|
1407
1407
|
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
@@ -1417,7 +1417,7 @@ async function connectChain(engine, entry) {
|
|
|
1417
1417
|
}
|
|
1418
1418
|
let target;
|
|
1419
1419
|
try {
|
|
1420
|
-
target = await connectClient(buildConnectConfig(entry, sock));
|
|
1420
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts));
|
|
1421
1421
|
return {
|
|
1422
1422
|
client: target,
|
|
1423
1423
|
hops
|
|
@@ -2145,6 +2145,51 @@ var SshEngine = class {
|
|
|
2145
2145
|
}
|
|
2146
2146
|
};
|
|
2147
2147
|
//#endregion
|
|
2148
|
+
//#region src/loopback.ts
|
|
2149
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
2150
|
+
function isIPv4Loopback(v4) {
|
|
2151
|
+
const parts = v4.split(".");
|
|
2152
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
2153
|
+
}
|
|
2154
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
2155
|
+
function isLoopbackAddress(address) {
|
|
2156
|
+
if (address === void 0) return false;
|
|
2157
|
+
const normalized = address.toLowerCase();
|
|
2158
|
+
if (normalized === "::1") return true;
|
|
2159
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
2160
|
+
return isIPv4Loopback(normalized);
|
|
2161
|
+
}
|
|
2162
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
2163
|
+
function isLoopbackHostname(hostname) {
|
|
2164
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
2165
|
+
return isIPv4Loopback(hostname);
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
2169
|
+
* header, plus browser same-origin markers. The socket address is
|
|
2170
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
2171
|
+
*/
|
|
2172
|
+
function isLoopbackRequest(request) {
|
|
2173
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
2174
|
+
const host = request.headers.host;
|
|
2175
|
+
if (typeof host !== "string") return false;
|
|
2176
|
+
let hostUrl;
|
|
2177
|
+
try {
|
|
2178
|
+
hostUrl = new URL("http://" + host);
|
|
2179
|
+
} catch {
|
|
2180
|
+
return false;
|
|
2181
|
+
}
|
|
2182
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
2183
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2184
|
+
const origin = request.headers.origin;
|
|
2185
|
+
if (origin === void 0) return true;
|
|
2186
|
+
try {
|
|
2187
|
+
return new URL(origin).host === hostUrl.host;
|
|
2188
|
+
} catch {
|
|
2189
|
+
return false;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
//#endregion
|
|
2148
2193
|
//#region src/protocol.ts
|
|
2149
2194
|
const SSH_API = {
|
|
2150
2195
|
hosts: "/api/dsh-ssh/hosts",
|
|
@@ -2182,28 +2227,6 @@ const terminalWss = new WebSocketServer({ noServer: true });
|
|
|
2182
2227
|
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
2183
2228
|
/** …and resume once it drains below this. */
|
|
2184
2229
|
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
2185
|
-
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
2186
|
-
function isLoopbackRequest(request) {
|
|
2187
|
-
const address = request.socket.remoteAddress;
|
|
2188
|
-
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
2189
|
-
const host = request.headers.host;
|
|
2190
|
-
if (typeof host !== "string") return false;
|
|
2191
|
-
let hostUrl;
|
|
2192
|
-
try {
|
|
2193
|
-
hostUrl = new URL(`http://${host}`);
|
|
2194
|
-
} catch {
|
|
2195
|
-
return false;
|
|
2196
|
-
}
|
|
2197
|
-
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
2198
|
-
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2199
|
-
const origin = request.headers.origin;
|
|
2200
|
-
if (origin === void 0) return true;
|
|
2201
|
-
try {
|
|
2202
|
-
return new URL(origin).host === hostUrl.host;
|
|
2203
|
-
} catch {
|
|
2204
|
-
return false;
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
2230
|
/** One JSON response. */
|
|
2208
2231
|
function writeJson(res, status, body) {
|
|
2209
2232
|
const payload = JSON.stringify(body);
|
|
@@ -5,6 +5,7 @@ export declare const zh: {
|
|
|
5
5
|
readonly 'entry.label': "SSH";
|
|
6
6
|
readonly 'entry.tooltip': "远程 SSH 运维面板";
|
|
7
7
|
readonly 'panel.title': "远程 SSH";
|
|
8
|
+
readonly 'panel.backToConversation': "返回会话";
|
|
8
9
|
readonly 'tab.hosts': "主机";
|
|
9
10
|
readonly 'tab.terminal': "终端";
|
|
10
11
|
readonly 'tab.transfer': "传输";
|
|
@@ -127,7 +128,6 @@ export declare const zh: {
|
|
|
127
128
|
readonly 'common.loading': "加载中…";
|
|
128
129
|
readonly 'common.error': "出错:{error}";
|
|
129
130
|
readonly 'common.refresh': "刷新";
|
|
130
|
-
readonly 'common.close': "关闭";
|
|
131
131
|
readonly 'common.confirm': "确定";
|
|
132
132
|
};
|
|
133
133
|
export declare const en: Record<keyof typeof zh, string>;
|
|
@@ -47,7 +47,7 @@ export interface PoolEngine {
|
|
|
47
47
|
readonly acquireQueue: Map<string, Promise<PoolRecord>>;
|
|
48
48
|
}
|
|
49
49
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
50
|
-
export declare function buildConnectConfig(entry: SshHostEntry, sock
|
|
50
|
+
export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
|
|
51
51
|
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
52
52
|
export declare function connectClient(config: ConnectConfig): Promise<Client>;
|
|
53
53
|
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
3
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
4
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
5
|
+
* re-run the sync instead of editing a copy.
|
|
6
|
+
*
|
|
7
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
8
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
9
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
10
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage } from 'node:http';
|
|
13
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
14
|
+
export declare function isIPv4Loopback(v4: string): boolean;
|
|
15
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
16
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
17
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
18
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
21
|
+
* header, plus browser same-origin markers. The socket address is
|
|
22
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isLoopbackRequest(request: IncomingMessage): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/locales.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const zh = {
|
|
|
6
6
|
'entry.label': 'SSH',
|
|
7
7
|
'entry.tooltip': '远程 SSH 运维面板',
|
|
8
8
|
'panel.title': '远程 SSH',
|
|
9
|
+
'panel.backToConversation': '返回会话',
|
|
9
10
|
'tab.hosts': '主机',
|
|
10
11
|
'tab.terminal': '终端',
|
|
11
12
|
'tab.transfer': '传输',
|
|
@@ -135,7 +136,6 @@ export const zh = {
|
|
|
135
136
|
'common.loading': '加载中…',
|
|
136
137
|
'common.error': '出错:{error}',
|
|
137
138
|
'common.refresh': '刷新',
|
|
138
|
-
'common.close': '关闭',
|
|
139
139
|
'common.confirm': '确定',
|
|
140
140
|
} as const
|
|
141
141
|
|
|
@@ -143,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
143
143
|
'entry.label': 'SSH',
|
|
144
144
|
'entry.tooltip': 'Remote SSH operations panel',
|
|
145
145
|
'panel.title': 'Remote SSH',
|
|
146
|
+
'panel.backToConversation': 'Back to chat',
|
|
146
147
|
'tab.hosts': 'Hosts',
|
|
147
148
|
'tab.terminal': 'Terminal',
|
|
148
149
|
'tab.transfer': 'Transfer',
|
|
@@ -265,7 +266,6 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
265
266
|
'common.loading': 'Loading…',
|
|
266
267
|
'common.error': 'Error: {error}',
|
|
267
268
|
'common.refresh': 'Refresh',
|
|
268
|
-
'common.close': 'Close',
|
|
269
269
|
'common.confirm': 'Confirm',
|
|
270
270
|
}
|
|
271
271
|
|
|
@@ -55,8 +55,16 @@ export function SshPanel({ controller, api }: SshPanelProps) {
|
|
|
55
55
|
return (
|
|
56
56
|
<div className={css.panel}>
|
|
57
57
|
<div className={css.panelHeader}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={`${css.ghostButton} ${css.backButton}`}
|
|
61
|
+
aria-label={tt('panel.backToConversation')}
|
|
62
|
+
onClick={() => { controller.close() }}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden="true">‹</span>
|
|
65
|
+
<span>{tt('panel.backToConversation')}</span>
|
|
66
|
+
</button>
|
|
58
67
|
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
59
|
-
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
|
|
60
68
|
</div>
|
|
61
69
|
<div className={css.tabBar} role="tablist">
|
|
62
70
|
{TABS.map(tab => (
|
|
@@ -133,6 +133,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
|
|
|
133
133
|
white-space: nowrap;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
.backButton {
|
|
137
|
+
display: inline-flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 4px;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
/* --- tab bar ------------------------------------------------------------------ */
|
|
137
143
|
|
|
138
144
|
.tabBar {
|
|
@@ -62,13 +62,13 @@ export interface PoolEngine {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
65
|
-
export function buildConnectConfig(entry: SshHostEntry, sock
|
|
65
|
+
export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig {
|
|
66
66
|
const config: ConnectConfig = {
|
|
67
67
|
host: entry.host,
|
|
68
68
|
port: entry.port,
|
|
69
69
|
username: entry.user,
|
|
70
|
-
readyTimeout:
|
|
71
|
-
keepaliveInterval:
|
|
70
|
+
readyTimeout: opts.connectTimeoutMs,
|
|
71
|
+
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
72
72
|
keepaliveCountMax: 3,
|
|
73
73
|
}
|
|
74
74
|
if (sock !== undefined) config.sock = sock
|
|
@@ -147,7 +147,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
147
147
|
for (const client of hops) client.end()
|
|
148
148
|
throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
|
|
149
149
|
}
|
|
150
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock))
|
|
150
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
|
|
151
151
|
hops.push(hopClient)
|
|
152
152
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
|
|
153
153
|
const nextHost = next !== undefined ? next.host : entry.host
|
|
@@ -165,7 +165,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
165
165
|
}
|
|
166
166
|
let target: Client | undefined
|
|
167
167
|
try {
|
|
168
|
-
target = await connectClient(buildConnectConfig(entry, sock))
|
|
168
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
|
|
169
169
|
return { client: target, hops }
|
|
170
170
|
} catch (error) {
|
|
171
171
|
for (const client of hops) client.end()
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
17
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
18
|
import type { SshEngine, ShellSession } from './engine.ts'
|
|
19
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
19
20
|
import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
20
21
|
import type { HostStore } from './store.ts'
|
|
21
22
|
|
|
@@ -38,29 +39,6 @@ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
|
38
39
|
/** …and resume once it drains below this. */
|
|
39
40
|
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
40
41
|
|
|
41
|
-
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
42
|
-
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
43
|
-
const address = request.socket.remoteAddress
|
|
44
|
-
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
45
|
-
const host = request.headers.host
|
|
46
|
-
if (typeof host !== 'string') return false
|
|
47
|
-
let hostUrl: URL
|
|
48
|
-
try {
|
|
49
|
-
hostUrl = new URL(`http://${host}`)
|
|
50
|
-
} catch {
|
|
51
|
-
return false
|
|
52
|
-
}
|
|
53
|
-
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
54
|
-
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
55
|
-
const origin = request.headers.origin
|
|
56
|
-
if (origin === undefined) return true
|
|
57
|
-
try {
|
|
58
|
-
return new URL(origin).host === hostUrl.host
|
|
59
|
-
} catch {
|
|
60
|
-
return false
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
42
|
/** One JSON response. */
|
|
65
43
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
66
44
|
const payload = JSON.stringify(body)
|