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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.6-develop.5612.1.d382eb2f33",
3
+ "version": "0.6.6-develop.5619.1.29f01e2c42",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -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
+ }
@@ -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 = isContainerRuntime() ? '0.0.0.0' : '127.0.0.1'
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(),