create-mercato-app 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5619.1.29f01e2c42
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/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import spawn from 'cross-spawn'
|
|
6
6
|
import { resolveSpawnCommand } from './dev-spawn-utils.mjs'
|
|
7
|
+
import { assertLocalSplashRequest } from './dev-splash-shared.mjs'
|
|
7
8
|
|
|
8
9
|
const FALSE_TOKENS = new Set(['0', 'false', 'no', 'off', 'disabled'])
|
|
9
10
|
const TOOL_DEFINITIONS = [
|
|
@@ -700,6 +701,12 @@ export function createDevSplashCodingFlow(options = {}) {
|
|
|
700
701
|
return true
|
|
701
702
|
}
|
|
702
703
|
|
|
704
|
+
const localCheck = assertLocalSplashRequest(req)
|
|
705
|
+
if (!localCheck.ok) {
|
|
706
|
+
writeJson(res, localCheck.status, { ok: false, error: localCheck.error })
|
|
707
|
+
return true
|
|
708
|
+
}
|
|
709
|
+
|
|
703
710
|
if (req.headers['x-om-dev-splash-token'] !== actionToken) {
|
|
704
711
|
writeJson(res, 403, { ok: false, error: 'Invalid splash action token.' })
|
|
705
712
|
return true
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import spawn from 'cross-spawn'
|
|
5
5
|
import { resolveSpawnCommand } from './dev-spawn-utils.mjs'
|
|
6
|
+
import { assertLocalSplashRequest } from './dev-splash-shared.mjs'
|
|
6
7
|
|
|
7
8
|
const FALSE_TOKENS = new Set(['0', 'false', 'no', 'off', 'disabled'])
|
|
8
9
|
const GITHUB_REMOTE_PATTERNS = [
|
|
@@ -731,6 +732,12 @@ export function createDevSplashGitRepoFlow(options = {}) {
|
|
|
731
732
|
return true
|
|
732
733
|
}
|
|
733
734
|
|
|
735
|
+
const localCheck = assertLocalSplashRequest(req)
|
|
736
|
+
if (!localCheck.ok) {
|
|
737
|
+
writeJson(res, localCheck.status, { ok: false, error: localCheck.error })
|
|
738
|
+
return true
|
|
739
|
+
}
|
|
740
|
+
|
|
734
741
|
if (req.headers['x-om-dev-splash-token'] !== actionToken) {
|
|
735
742
|
writeJson(res, 403, { ok: false, error: 'Invalid splash action token.' })
|
|
736
743
|
return true
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const LOCAL_HOST_NAMES = ['localhost', '127.0.0.1', '::1', '[::1]']
|
|
2
|
+
|
|
3
|
+
function parseHostHeader(value) {
|
|
4
|
+
if (typeof value !== 'string') return null
|
|
5
|
+
const trimmed = value.trim()
|
|
6
|
+
if (!trimmed) return null
|
|
7
|
+
if (trimmed.startsWith('[')) {
|
|
8
|
+
const closingBracket = trimmed.indexOf(']')
|
|
9
|
+
if (closingBracket === -1) return null
|
|
10
|
+
return trimmed.slice(0, closingBracket + 1).toLowerCase()
|
|
11
|
+
}
|
|
12
|
+
const colon = trimmed.lastIndexOf(':')
|
|
13
|
+
const host = colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
14
|
+
return host.toLowerCase()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Extract a port-less hostname from an allowlist entry. Accepts a full origin
|
|
18
|
+
// (`https://foo.example:4000`), a host:port (`foo.example:4000`), or a bare
|
|
19
|
+
// hostname (`foo.example`). Returns the hostname lowercased, or null when the
|
|
20
|
+
// entry cannot be parsed.
|
|
21
|
+
function extractHostFromAllowlistEntry(entry) {
|
|
22
|
+
if (typeof entry !== 'string') return null
|
|
23
|
+
const trimmed = entry.trim()
|
|
24
|
+
if (!trimmed) return null
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(trimmed)
|
|
27
|
+
// `url.hostname` is port-less ("foo.example" or "[::1]"). `url.host`
|
|
28
|
+
// would include the port and would not match parseHostHeader's output.
|
|
29
|
+
const hostname = url.hostname?.toLowerCase()
|
|
30
|
+
if (hostname) {
|
|
31
|
+
// URL strips IPv6 brackets from hostname; normalize back so it matches
|
|
32
|
+
// the bracketed form that parseHostHeader produces from a Host header.
|
|
33
|
+
return hostname.includes(':') && !hostname.startsWith('[')
|
|
34
|
+
? `[${hostname}]`
|
|
35
|
+
: hostname
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Not a valid URL — fall through to host:port parsing.
|
|
39
|
+
}
|
|
40
|
+
return parseHostHeader(trimmed)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parses an ALLOWED_ORIGINS-style env value (Next.js convention,
|
|
44
|
+
// comma-separated origins/hosts). Each entry is reduced to a port-less
|
|
45
|
+
// hostname so comparison stays consistent with `parseHostHeader` output.
|
|
46
|
+
function parseAllowedOriginsEnv(envValue) {
|
|
47
|
+
if (typeof envValue !== 'string') return []
|
|
48
|
+
return envValue
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((part) => part.trim())
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map(extractHostFromAllowlistEntry)
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolves the effective allowed-host set. Always includes loopback names;
|
|
57
|
+
// extends with hosts from the `ALLOWED_ORIGINS` env var (Next.js convention).
|
|
58
|
+
// Sandbox / preview / dev-container deployments add their public hostname
|
|
59
|
+
// there to be accepted by the splash action guard without bypassing the
|
|
60
|
+
// DNS-rebinding defense.
|
|
61
|
+
export function resolveAllowedSplashHosts(env = process.env) {
|
|
62
|
+
const allowed = new Set(LOCAL_HOST_NAMES)
|
|
63
|
+
for (const host of parseAllowedOriginsEnv(env?.ALLOWED_ORIGINS)) {
|
|
64
|
+
allowed.add(host)
|
|
65
|
+
}
|
|
66
|
+
return allowed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isLocalSplashHost(value, env = process.env) {
|
|
70
|
+
const host = parseHostHeader(value)
|
|
71
|
+
if (!host) return false
|
|
72
|
+
return resolveAllowedSplashHosts(env).has(host)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Missing/empty Origin is acceptable (non-browser CLIs do not always send one).
|
|
76
|
+
// Literal `"null"` is the sentinel browsers send from sandboxed iframes / opaque
|
|
77
|
+
// origins, so it must be rejected. Parsable origins must resolve to either a
|
|
78
|
+
// loopback host or an entry on the `ALLOWED_ORIGINS` allowlist.
|
|
79
|
+
export function isAcceptableSplashOrigin(originHeader, env = process.env) {
|
|
80
|
+
if (originHeader == null) return true
|
|
81
|
+
const trimmed = String(originHeader).trim()
|
|
82
|
+
if (trimmed === '') return true
|
|
83
|
+
if (trimmed === 'null') return false
|
|
84
|
+
let parsed
|
|
85
|
+
try {
|
|
86
|
+
parsed = new URL(trimmed)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
return isLocalSplashHost(parsed.host, env)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function assertLocalSplashRequest(req, env = process.env) {
|
|
94
|
+
const headers = req?.headers ?? {}
|
|
95
|
+
if (!isLocalSplashHost(headers.host, env)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
status: 403,
|
|
99
|
+
error: 'Splash actions are only accessible from a loopback host or an entry in ALLOWED_ORIGINS.',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!isAcceptableSplashOrigin(headers.origin, env)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
status: 403,
|
|
106
|
+
error: 'Splash actions are only accessible from the splash origin.',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ok: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Splash bind defaults to loopback. Operators who deliberately want network-
|
|
113
|
+
// reachable splash (for example a container exposing the splash port to the
|
|
114
|
+
// host) opt in via OM_DEV_SPLASH_BIND. The Host/Origin guard on action
|
|
115
|
+
// endpoints defends browsers against DNS rebinding, but it CANNOT stop a
|
|
116
|
+
// network-resident attacker from spoofing Host/Origin headers with a raw HTTP
|
|
117
|
+
// client — the opt-in is "I trust everything that can reach this port".
|
|
118
|
+
export function resolveSplashBindHost(env = process.env, logger = console) {
|
|
119
|
+
const raw = typeof env?.OM_DEV_SPLASH_BIND === 'string'
|
|
120
|
+
? env.OM_DEV_SPLASH_BIND.trim()
|
|
121
|
+
: ''
|
|
122
|
+
const normalized = raw.toLowerCase()
|
|
123
|
+
if (normalized === '0.0.0.0' || normalized === 'all') {
|
|
124
|
+
logger?.warn?.('⚠️ OM_DEV_SPLASH_BIND=0.0.0.0 — splash server is reachable from the network. Mutating action endpoints (GitHub publish, coding tool spawn) become exposed to anyone who can reach this port; the Host/Origin guard only stops browser-based DNS rebinding, not raw HTTP clients. Only enable on trusted networks.')
|
|
125
|
+
return '0.0.0.0'
|
|
126
|
+
}
|
|
127
|
+
if (normalized === '::' || normalized === '::0') {
|
|
128
|
+
logger?.warn?.('⚠️ OM_DEV_SPLASH_BIND=:: — splash server is reachable from the network. Mutating action endpoints become exposed to anyone who can reach this port. Only enable on trusted networks.')
|
|
129
|
+
return '::'
|
|
130
|
+
}
|
|
131
|
+
if (normalized === '::1') return '::1'
|
|
132
|
+
if (normalized === '127.0.0.1' || normalized === 'localhost') return '127.0.0.1'
|
|
133
|
+
if (normalized) {
|
|
134
|
+
logger?.warn?.(`⚠️ Unrecognized OM_DEV_SPLASH_BIND="${raw}" — falling back to loopback (127.0.0.1).`)
|
|
135
|
+
}
|
|
136
|
+
return '127.0.0.1'
|
|
137
|
+
}
|
package/template/scripts/dev.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { killProcessTree } from './dev-shutdown-utils.mjs'
|
|
|
27
27
|
import { resolveSpawnCommand } from './dev-spawn-utils.mjs'
|
|
28
28
|
import { createDevSplashCodingFlow } from './dev-splash-coding-flow.mjs'
|
|
29
29
|
import { createDevSplashGitRepoFlow } from './dev-splash-git-repo-flow.mjs'
|
|
30
|
+
import { resolveSplashBindHost } from './dev-splash-shared.mjs'
|
|
30
31
|
import { normalizeSplashDisplayState } from './dev-splash-state.mjs'
|
|
31
32
|
import {
|
|
32
33
|
resolveDevBaseUrl,
|
|
@@ -119,10 +120,6 @@ function shouldRefreshStandaloneRegistryPackages() {
|
|
|
119
120
|
return !hasExistingStandaloneInstall()
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
function isContainerRuntime() {
|
|
123
|
-
return fs.existsSync('/.dockerenv')
|
|
124
|
-
}
|
|
125
|
-
|
|
126
123
|
function parsePortNumber(value) {
|
|
127
124
|
if (typeof value !== 'string' && typeof value !== 'number') return null
|
|
128
125
|
const parsed = Number.parseInt(String(value).trim(), 10)
|
|
@@ -200,7 +197,7 @@ const splashMode = greenfield ? 'greenfield' : setupMode ? 'setup' : 'dev'
|
|
|
200
197
|
const standaloneStageTotal = setupMode ? 5 : 4
|
|
201
198
|
const splashEnabled = !classic && !appOnly && splashPortConfig.enabled
|
|
202
199
|
const autoOpenSplash = splashEnabled && process.stdout.isTTY && process.env.CI !== 'true' && process.env.OM_DEV_AUTO_OPEN !== '0'
|
|
203
|
-
const splashBindHost =
|
|
200
|
+
const splashBindHost = resolveSplashBindHost(process.env)
|
|
204
201
|
const standaloneRuntimeScript = path.join(process.cwd(), 'scripts', 'dev-runtime.mjs')
|
|
205
202
|
const warmupReadyFilePath = path.join(
|
|
206
203
|
process.cwd(),
|