@linxin666/dsh-usage 0.4.6 → 0.4.8

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/index.js CHANGED
@@ -118,7 +118,7 @@ function mountOnce(packageName, fn) {
118
118
  return mount;
119
119
  }
120
120
  //#endregion
121
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.2.0-rc.2_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-brand/lib/index.js
121
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.2.1-rc.2_@deepseek-ai+cordis@4.0.5/node_modules/@deepseek-ai/dsh-brand/lib/index.js
122
122
  /**
123
123
  * Duplicate-install-safe nominal primitive helpers.
124
124
  *
@@ -141,7 +141,7 @@ function brandString(value) {
141
141
  return value;
142
142
  }
143
143
  //#endregion
144
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.2.0-rc.2_@deepseek-ai+cordis@4.0.4_@deepseek-ai+dsh-inva_13783eb73d2217c5a4a149b781a9cc49/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
144
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.2.1-rc.2_@deepseek-ai+cordis@4.0.5/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
145
145
  /**
146
146
  * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
147
147
  * *references* to secrets — environment-variable names — while providers own
@@ -2025,12 +2025,26 @@ function isLoopbackHostname(hostname) {
2025
2025
  return isIPv4Loopback(hostname);
2026
2026
  }
2027
2027
  /**
2028
+ * Whether a reverse proxy stood between the client and this host: the request
2029
+ * carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
2030
+ * hop that forwards traffic, so the request did not originate on this machine.
2031
+ * @param request - node HTTP request.
2032
+ * @returns true when a forwarding header is present.
2033
+ */
2034
+ function isProxiedRequest(request) {
2035
+ return request.headers["cf-connecting-ip"] !== void 0 || request.headers["x-forwarded-for"] !== void 0;
2036
+ }
2037
+ /**
2028
2038
  * Request-level trust fence: a loopback socket address AND a loopback Host
2029
2039
  * header, plus browser same-origin markers. The socket address is
2030
- * authoritative; X-Forwarded-For is never trusted.
2040
+ * authoritative for a direct connection; a request that carries a forwarding
2041
+ * header is refused outright, because a proxy in front of the host makes both
2042
+ * the socket address and the Host header describe the proxy rather than the
2043
+ * client. X-Forwarded-For is therefore never evidence *for* loopback.
2031
2044
  */
2032
2045
  function isLoopbackRequest(request) {
2033
2046
  if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
2047
+ if (isProxiedRequest(request)) return false;
2034
2048
  const host = request.headers.host;
2035
2049
  if (typeof host !== "string") return false;
2036
2050
  let hostUrl;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * DeepSeek pricing-clock marker, shared by the usage panel's peak line and the
3
+ * sidebar foot card: a filled dot plus the period word, red while the peak
4
+ * window bills double and green while off-peak bills half. The word always
5
+ * rides with the colour (colour alone is not a signal), and the priced
6
+ * sentence moves to the title so a tight surface stays one short token.
7
+ * @module @linxin666/dsh-usage/client/PeakBadge
8
+ */
9
+ import type { ReactNode } from 'react';
10
+ /** Props: where the clock stands and how much copy the surface can hold. */
11
+ export interface PeakBadgeProps {
12
+ /** Whether the published DeepSeek peak window is in force now. */
13
+ peak: boolean;
14
+ /** End of the current period (the peak close), or the next peak open when off-peak. */
15
+ boundaryMs: number;
16
+ /** compact: dot plus the period word; full: the whole priced sentence. */
17
+ variant: 'compact' | 'full';
18
+ }
19
+ /** HH:MM of one instant; an unparsable instant drops the time. */
20
+ export declare function formatClock(ms: number): string;
21
+ /** The peak/off-peak marker itself; nothing renders outside the DeepSeek family. */
22
+ export declare function PeakBadge(props: PeakBadgeProps): ReactNode;
@@ -29,6 +29,10 @@ export declare const zh: {
29
29
  'usage.day.error': string;
30
30
  'usage.peak.on': string;
31
31
  'usage.peak.off': string;
32
+ 'usage.peak.tag.on': string;
33
+ 'usage.peak.tag.off': string;
34
+ 'usage.peak.ends': string;
35
+ 'usage.peak.starts': string;
32
36
  'usage.calls': string;
33
37
  'usage.tokens.total': string;
34
38
  'usage.tokens.input': string;
@@ -7,6 +7,14 @@
7
7
  * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
8
8
  * remote-web-ui gate), localhost hostnames, plus the browser same-origin
9
9
  * markers (sec-fetch-site and Origin) for the request-level fence.
10
+ *
11
+ * A reverse proxy in front of the host rewrites the Host header to its own
12
+ * upstream literal (a Cloudflare tunnel, frp and an nginx reverse proxy all
13
+ * dial 127.0.0.1:<port>), and the socket the origin sees is the proxy's, which
14
+ * is loopback by construction. Both halves of the fence then hold for a public
15
+ * request. Forwarding headers are the signal that a hop stands in between: a
16
+ * request that carries CF-Connecting-IP or X-Forwarded-For is answered through
17
+ * a proxy and is never treated as a local one (issue #1906).
10
18
  * @module dsh-web-shared/host/loopback
11
19
  */
12
20
  import type { IncomingMessage } from 'node:http';
@@ -16,9 +24,20 @@ export declare function isIPv4Loopback(v4: string): boolean;
16
24
  export declare function isLoopbackAddress(address: string | undefined): boolean;
17
25
  /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
18
26
  export declare function isLoopbackHostname(hostname: string): boolean;
27
+ /**
28
+ * Whether a reverse proxy stood between the client and this host: the request
29
+ * carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
30
+ * hop that forwards traffic, so the request did not originate on this machine.
31
+ * @param request - node HTTP request.
32
+ * @returns true when a forwarding header is present.
33
+ */
34
+ export declare function isProxiedRequest(request: IncomingMessage): boolean;
19
35
  /**
20
36
  * Request-level trust fence: a loopback socket address AND a loopback Host
21
37
  * header, plus browser same-origin markers. The socket address is
22
- * authoritative; X-Forwarded-For is never trusted.
38
+ * authoritative for a direct connection; a request that carries a forwarding
39
+ * header is refused outright, because a proxy in front of the host makes both
40
+ * the socket address and the Host header describe the proxy rather than the
41
+ * client. X-Forwarded-For is therefore never evidence *for* loopback.
23
42
  */
24
43
  export declare function isLoopbackRequest(request: IncomingMessage): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-usage",
3
3
  "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -33,7 +33,7 @@
33
33
  "license": "Apache-2.0",
34
34
  "dsh": {
35
35
  "engines": {
36
- "dsh": ">=0.2.0-rc.2"
36
+ "dsh": ">=0.2.1-rc.2"
37
37
  },
38
38
  "bundle": {
39
39
  "patch": "./cordis.patch.yml"
@@ -50,25 +50,25 @@
50
50
  }
51
51
  },
52
52
  "dependencies": {
53
- "@deepseek-ai/schemastery": "^3.18.4"
53
+ "@deepseek-ai/schemastery": "^3.18.5"
54
54
  },
55
55
  "peerDependencies": {
56
- "@deepseek-ai/dsh": ">=0.2.0-rc.2",
56
+ "@deepseek-ai/dsh": ">=0.2.1-rc.2",
57
57
  "react": "^18.2.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@deepseek-ai/cordis": "^4.0.4",
61
- "@deepseek-ai/dsh-credentials": "^0.2.0-rc.2",
62
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
63
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
64
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
65
- "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
66
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
67
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
68
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
69
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
70
- "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
71
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
60
+ "@deepseek-ai/cordis": "^4.0.5",
61
+ "@deepseek-ai/dsh-credentials": "^0.2.1-rc.2",
62
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.2",
63
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-rc.2",
64
+ "@deepseek-ai/dsh-client-store": "^0.2.1-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-rc.2",
67
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.2",
68
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2",
69
+ "@deepseek-ai/dsh-llm": "^0.2.1-rc.2",
70
+ "@deepseek-ai/dsh-session": "^0.2.1-rc.2",
71
+ "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
72
72
  "@testing-library/dom": "^10.4.1",
73
73
  "@testing-library/react": "^16.3.2",
74
74
  "@types/node": "^22.20.0",
@@ -90,6 +90,6 @@
90
90
  "build": "tsc -b tsconfig.build.json && tsdown",
91
91
  "watch": "tsdown --watch",
92
92
  "test": "vitest run",
93
- "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --pretty false"
93
+ "typecheck": "tsgo --noEmit && tsgo -p tsconfig.test.json --pretty false"
94
94
  }
95
95
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * DeepSeek pricing-clock marker, shared by the usage panel's peak line and the
3
+ * sidebar foot card: a filled dot plus the period word, red while the peak
4
+ * window bills double and green while off-peak bills half. The word always
5
+ * rides with the colour (colour alone is not a signal), and the priced
6
+ * sentence moves to the title so a tight surface stays one short token.
7
+ * @module @linxin666/dsh-usage/client/PeakBadge
8
+ */
9
+
10
+ import type { ReactNode } from 'react'
11
+ import { t } from './locales.ts'
12
+ import styles from './usage.module.css'
13
+
14
+ /** Props: where the clock stands and how much copy the surface can hold. */
15
+ export interface PeakBadgeProps {
16
+ /** Whether the published DeepSeek peak window is in force now. */
17
+ peak: boolean
18
+ /** End of the current period (the peak close), or the next peak open when off-peak. */
19
+ boundaryMs: number
20
+ /** compact: dot plus the period word; full: the whole priced sentence. */
21
+ variant: 'compact' | 'full'
22
+ }
23
+
24
+ /** HH:MM of one instant; an unparsable instant drops the time. */
25
+ export function formatClock(ms: number): string {
26
+ try {
27
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
28
+ } catch {
29
+ return ''
30
+ }
31
+ }
32
+
33
+ /** The peak/off-peak marker itself; nothing renders outside the DeepSeek family. */
34
+ export function PeakBadge(props: PeakBadgeProps): ReactNode {
35
+ const { peak, boundaryMs, variant } = props
36
+ const sentence = t(peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(boundaryMs) })
37
+ return (
38
+ <span
39
+ className={variant === 'full' ? styles.peakLine : styles.peakBadge}
40
+ data-peak={peak ? 'on' : 'off'}
41
+ data-dsh-part="peak-status"
42
+ title={sentence}
43
+ >
44
+ <span className={styles.peakDot} aria-hidden="true" />
45
+ {variant === 'full' ? sentence : t(peak ? 'usage.peak.tag.on' : 'usage.peak.tag.off')}
46
+ </span>
47
+ )
48
+ }
@@ -27,7 +27,10 @@ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react
27
27
  import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
28
28
  import { t } from './locales.ts'
29
29
  import styles from './usage.module.css'
30
+ import { formatClock, PeakBadge } from './PeakBadge.tsx'
30
31
  import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
32
+ import { deepseekPeriodAt } from '../core/pricing.ts'
33
+ import { isDeepSeekProviderRoute } from '../core/adapters.ts'
31
34
  import { totalTokens } from '../core/ledger.ts'
32
35
  import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
33
36
  import type { UsageStoreInstance } from './usage-store.ts'
@@ -114,27 +117,23 @@ function formatBalance(balance: BalanceView): string {
114
117
  return currency + ' ' + balance.totalBalance
115
118
  }
116
119
 
117
- function formatClock(ms: number): string {
118
- try {
119
- return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
120
- } catch {
121
- return ''
122
- }
123
- }
124
-
125
120
  /**
126
- * Display name of the provider owning today's priced spend: the day's highest
127
- * cost row, else the current session route, resolved through the provider
128
- * snapshot (an older host without the day rows falls back to the current
129
- * route). Undefined when there is no route to name.
121
+ * Route key owning today's priced spend: the day's highest cost row, else the
122
+ * current session route (an older host without the day rows falls back to the
123
+ * current route). Undefined when neither names one.
130
124
  */
131
- function spendProvider(snapshot: UsageOverviewView): string | undefined {
125
+ function spendRoute(snapshot: UsageOverviewView): string | undefined {
132
126
  let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
133
127
  for (const row of snapshot.usage.today.providers) {
134
128
  if (row.totals.cost <= 0) continue
135
129
  if (top === undefined || row.totals.cost > top.totals.cost) top = row
136
130
  }
137
- const route = top?.provider ?? snapshot.current.provider
131
+ return top?.provider ?? snapshot.current.provider
132
+ }
133
+
134
+ /** Display name of the route spendRoute() names, resolved through the snapshot. */
135
+ function spendProvider(snapshot: UsageOverviewView): string | undefined {
136
+ const route = spendRoute(snapshot)
138
137
  if (route === undefined) return undefined
139
138
  return snapshot.providers.find((entry) => entry.provider === route)?.displayName
140
139
  ?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
@@ -266,6 +265,11 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
266
265
 
267
266
  const head = headline(snapshot)
268
267
  const balances = balanceLine(snapshot)
268
+ // The published peak/off-peak clock prices the official DeepSeek family only,
269
+ // so the marker follows the route that owns today's spend (else the session
270
+ // route) and stays away from every other provider's card.
271
+ const route = spendRoute(snapshot)
272
+ const period = route !== undefined && isDeepSeekProviderRoute(route) ? deepseekPeriodAt(Date.now()) : undefined
269
273
 
270
274
  if (collapsed) {
271
275
  return (
@@ -274,6 +278,7 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
274
278
  <span className={styles.footStrip} data-dsh-part="foot-card-strip">
275
279
  <GaugeIcon />
276
280
  {head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
281
+ {period !== undefined && <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />}
277
282
  <span className={styles.footStripLabel}>{head.label}</span>
278
283
  <span className={styles.footStripValue}>{head.value}</span>
279
284
  </span>
@@ -292,6 +297,14 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
292
297
  </span>
293
298
  <span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
294
299
  {balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
300
+ {period !== undefined && (
301
+ <span className={styles.footPeak}>
302
+ <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />
303
+ <span className={styles.footPeakClock}>
304
+ {t(period.peak ? 'usage.peak.ends' : 'usage.peak.starts', { time: formatClock(period.boundaryMs) })}
305
+ </span>
306
+ </span>
307
+ )}
295
308
  <span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
296
309
  </button>
297
310
  {toggle}
@@ -13,6 +13,7 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
13
13
  import type { UsageStoreInstance } from './usage-store.ts'
14
14
  import { t } from './locales.ts'
15
15
  import styles from './usage.module.css'
16
+ import { PeakBadge } from './PeakBadge.tsx'
16
17
  import { isDeepSeekProviderRoute } from '../core/adapters.ts'
17
18
  import { deepseekPeriodAt } from '../core/pricing.ts'
18
19
  import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
@@ -77,14 +78,6 @@ function formatTime(ms: number): string {
77
78
  }
78
79
  }
79
80
 
80
- function formatClock(ms: number): string {
81
- try {
82
- return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
83
- } catch {
84
- return ''
85
- }
86
- }
87
-
88
81
  /** Formatted CNY spend estimate (the only priced currency today). */
89
82
  function formatCost(cost: number): string {
90
83
  return '¥' + cost.toFixed(2)
@@ -389,9 +382,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
389
382
  onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
390
383
  />
391
384
  {deepseekVisible && (
392
- <span className={styles.muted} data-dsh-part="peak-status">
393
- {t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
394
- </span>
385
+ <PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
395
386
  )}
396
387
  {usageDayLoad?.status === 'error'
397
388
  ? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
@@ -31,6 +31,10 @@ export const zh = {
31
31
  'usage.day.error': '该日用量加载失败:{error}',
32
32
  'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
33
33
  'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
34
+ 'usage.peak.tag.on': '高峰',
35
+ 'usage.peak.tag.off': '空闲',
36
+ 'usage.peak.ends': '{time} 结束',
37
+ 'usage.peak.starts': '{time} 进入高峰',
34
38
  'usage.calls': '{n} 次调用',
35
39
  'usage.tokens.total': '总 tokens',
36
40
  'usage.tokens.input': '输入',
@@ -98,6 +102,10 @@ export const en: Record<UsageKey, string> = {
98
102
  'usage.day.error': 'Failed to load that day: {error}',
99
103
  'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
100
104
  'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
105
+ 'usage.peak.tag.on': 'Peak',
106
+ 'usage.peak.tag.off': 'Off-peak',
107
+ 'usage.peak.ends': 'ends {time}',
108
+ 'usage.peak.starts': 'peak at {time}',
101
109
  'usage.calls': '{n} calls',
102
110
  'usage.tokens.total': 'Total tokens',
103
111
  'usage.tokens.input': 'Input',
@@ -52,7 +52,6 @@ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
52
52
  user: undefined,
53
53
  revision: undefined,
54
54
  writable: false,
55
- mode: 'host',
56
55
  }
57
56
  }
58
57
 
@@ -594,7 +594,7 @@
594
594
 
595
595
  .footStripProvider {
596
596
  flex: none;
597
- max-width: 45%;
597
+ max-width: 38%;
598
598
  overflow: hidden;
599
599
  text-overflow: ellipsis;
600
600
  font-weight: 500;
@@ -616,3 +616,61 @@
616
616
  :global([data-sidebar-collapsed]) .footCard {
617
617
  display: none;
618
618
  }
619
+
620
+ /* ---------------------------------------------------------------------------
621
+ DeepSeek pricing clock: red while the peak window bills double, green while
622
+ off-peak bills half. The period word rides with the dot, so colour is never
623
+ the only signal, and the priced sentence lives in the title attribute.
624
+ --------------------------------------------------------------------------- */
625
+
626
+ .peakBadge,
627
+ .peakLine {
628
+ display: inline-flex;
629
+ align-items: center;
630
+ gap: 4px;
631
+ font-size: 11px;
632
+ font-variant-numeric: tabular-nums;
633
+ white-space: nowrap;
634
+ }
635
+
636
+ .peakBadge {
637
+ flex: none;
638
+ }
639
+
640
+ .peakLine {
641
+ gap: 6px;
642
+ }
643
+
644
+ .peakBadge[data-peak='on'],
645
+ .peakLine[data-peak='on'] {
646
+ color: var(--dsw-alias-state-error-primary, #b42318);
647
+ }
648
+
649
+ .peakBadge[data-peak='off'],
650
+ .peakLine[data-peak='off'] {
651
+ color: var(--dsw-alias-state-success-primary, #067647);
652
+ }
653
+
654
+ .peakDot {
655
+ flex: none;
656
+ width: 6px;
657
+ height: 6px;
658
+ border-radius: 50%;
659
+ background: currentColor;
660
+ }
661
+
662
+ /* Expanded foot card: its own line at full opacity (the meta line below is
663
+ dimmed) so the red/green reads on every skin. */
664
+ .footPeak {
665
+ display: flex;
666
+ align-items: center;
667
+ gap: 5px;
668
+ overflow: hidden;
669
+ font-size: 10px;
670
+ white-space: nowrap;
671
+ }
672
+
673
+ .footPeakClock {
674
+ opacity: 0.7;
675
+ font-variant-numeric: tabular-nums;
676
+ }
@@ -8,6 +8,14 @@
8
8
  * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
9
  * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
10
  * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ *
12
+ * A reverse proxy in front of the host rewrites the Host header to its own
13
+ * upstream literal (a Cloudflare tunnel, frp and an nginx reverse proxy all
14
+ * dial 127.0.0.1:<port>), and the socket the origin sees is the proxy's, which
15
+ * is loopback by construction. Both halves of the fence then hold for a public
16
+ * request. Forwarding headers are the signal that a hop stands in between: a
17
+ * request that carries CF-Connecting-IP or X-Forwarded-For is answered through
18
+ * a proxy and is never treated as a local one (issue #1906).
11
19
  * @module dsh-web-shared/host/loopback
12
20
  */
13
21
 
@@ -36,13 +44,29 @@ export function isLoopbackHostname(hostname: string): boolean {
36
44
  return isIPv4Loopback(hostname)
37
45
  }
38
46
 
47
+ /**
48
+ * Whether a reverse proxy stood between the client and this host: the request
49
+ * carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
50
+ * hop that forwards traffic, so the request did not originate on this machine.
51
+ * @param request - node HTTP request.
52
+ * @returns true when a forwarding header is present.
53
+ */
54
+ export function isProxiedRequest(request: IncomingMessage): boolean {
55
+ return request.headers['cf-connecting-ip'] !== undefined
56
+ || request.headers['x-forwarded-for'] !== undefined
57
+ }
58
+
39
59
  /**
40
60
  * Request-level trust fence: a loopback socket address AND a loopback Host
41
61
  * header, plus browser same-origin markers. The socket address is
42
- * authoritative; X-Forwarded-For is never trusted.
62
+ * authoritative for a direct connection; a request that carries a forwarding
63
+ * header is refused outright, because a proxy in front of the host makes both
64
+ * the socket address and the Host header describe the proxy rather than the
65
+ * client. X-Forwarded-For is therefore never evidence *for* loopback.
43
66
  */
44
67
  export function isLoopbackRequest(request: IncomingMessage): boolean {
45
68
  if (!isLoopbackAddress(request.socket.remoteAddress)) return false
69
+ if (isProxiedRequest(request)) return false
46
70
  const host = request.headers.host
47
71
  if (typeof host !== 'string') return false
48
72
  let hostUrl: URL