@homebridge-wyze-node24/homebridge-wyze-node24 1.1.0 → 1.1.2

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.
@@ -16,30 +16,43 @@
16
16
  },
17
17
  "username": {
18
18
  "title": "Username (E-Mail Address)",
19
- "description": "The e-mail address used for your Wyze account",
19
+ "description": "The e-mail address used for your Wyze account. You can also set WYZE_USERNAME env var or use secretsFile.",
20
20
  "type": "string",
21
21
  "default": "",
22
22
  "required": true
23
23
  },
24
24
  "password": {
25
25
  "title": "Password",
26
- "description": "The password used for your Wyze account",
26
+ "description": "The password used for your Wyze account. You can also set WYZE_PASSWORD env var or use secretsFile.",
27
27
  "type": "string",
28
+ "format": "password",
28
29
  "default": "",
29
30
  "required": true
30
31
  },
31
32
  "keyId": {
32
33
  "title": "Key ID",
33
- "description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com",
34
+ "description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com. You can also set WYZE_KEY_ID env var or use secretsFile.",
34
35
  "type": "string",
36
+ "format": "password",
35
37
  "required": true
36
38
  },
37
39
  "apiKey": {
38
40
  "title": "API Key",
39
- "description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com/)",
41
+ "description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com. You can also set WYZE_API_KEY env var or use secretsFile.",
40
42
  "type": "string",
43
+ "format": "password",
41
44
  "required": true
42
45
  },
46
+ "secretsFile": {
47
+ "title": "Secrets file path (optional)",
48
+ "description": "Path to a local JSON file containing secrets (e.g., username/password/apiKey/etc). File must be chmod 600 (owner-only) or the plugin will refuse to start.",
49
+ "type": "string",
50
+ "default": "",
51
+ "required": false,
52
+ "condition": {
53
+ "functionBody": "return model.showAdvancedOptions === true;"
54
+ }
55
+ },
43
56
  "refreshInterval": {
44
57
  "title": "Refresh Interval",
45
58
  "description": "Specify the number of milliseconds to wait between updates, default is 60000 ms (60 seconds)",
@@ -63,7 +76,7 @@
63
76
  },
64
77
  "apiLogEnabled": {
65
78
  "title": "Enable API logging",
66
- "description": "",
79
+ "description": "WARNING: may log request/response details from upstream libraries. Keep OFF unless you understand the risk.",
67
80
  "type": "boolean",
68
81
  "default": false,
69
82
  "condition": {
@@ -79,6 +92,15 @@
79
92
  "functionBody": "return model.showAdvancedOptions === true;"
80
93
  }
81
94
  },
95
+ "dangerouslyAllowCustomBaseUrls": {
96
+ "title": "Dangerously allow custom API base URLs",
97
+ "description": "SECURITY WARNING: If enabled, the plugin will accept authBaseUrl/apiBaseUrl overrides. This can enable SSRF and credential exfiltration. Only enable if you know exactly what you're doing.",
98
+ "type": "boolean",
99
+ "default": false,
100
+ "condition": {
101
+ "functionBody": "return model.showAdvancedOptions === true;"
102
+ }
103
+ },
82
104
  "lowBatteryPercentage": {
83
105
  "title": "Low Battery Percentage",
84
106
  "description": "Specify the Percentage of battery when consider low, default is 30%",
@@ -204,4 +226,4 @@
204
226
  },
205
227
  "form": null,
206
228
  "display": null
207
- }
229
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebridge-wyze-node24/homebridge-wyze-node24",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Wyze Smart Home plugin for Homebridge compatible with Node.JS 24",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -3,6 +3,13 @@ const { OutdoorPlugModels, PlugModels, CommonModels, CameraModels, LeakSensorMod
3
3
  TemperatureHumidityModels, LockModels, MotionSensorModels, ContactSensorModels, LightModels,
4
4
  LightStripModels, MeshLightModels, ThermostatModels, S1GatewayModels } = require('./enums')
5
5
 
6
+ const {
7
+ getValidatedBaseUrls,
8
+ resolveSecrets,
9
+ sanitizeDeviceName,
10
+ wrapLogger
11
+ } = require('./security')
12
+
6
13
  const WyzeAPI = require('wyze-api') // Uncomment for Release
7
14
  //const WyzeAPI = require('./wyze-api/src') // Comment for Release
8
15
  const WyzePlug = require('./accessories/WyzePlug')
@@ -29,8 +36,8 @@ function delay(ms) {
29
36
 
30
37
  module.exports = class WyzeSmartHome {
31
38
  constructor(log, config, api) {
32
- this.log = log
33
- this.config = config
39
+ this.log = wrapLogger(log)
40
+ this.config = resolveSecrets(config || {}, this.log)
34
41
  this.api = api
35
42
  this.client = this.getClient()
36
43
 
@@ -44,6 +51,8 @@ module.exports = class WyzeSmartHome {
44
51
  }
45
52
 
46
53
  getClient() {
54
+ const { authBaseUrl, apiBaseUrl } = getValidatedBaseUrls(this.config, this.log)
55
+
47
56
  return new WyzeAPI({
48
57
  // User login parameters
49
58
  username: this.config.username,
@@ -57,9 +66,9 @@ module.exports = class WyzeSmartHome {
57
66
  lowBatteryPercentage: this.config.lowBatteryPercentage,
58
67
  //Storage Path
59
68
  persistPath: homebridge.user.persistPath(),
60
- //URLs
61
- authBaseUrl: this.config.authBaseUrl,
62
- apiBaseUrl: this.config.apiBaseUrl,
69
+ //URLs (strictly validated)
70
+ authBaseUrl,
71
+ apiBaseUrl,
63
72
  // App emulation constants
64
73
  authApiKey: this.config.authApiKey,
65
74
  phoneId: this.config.phoneId,
@@ -83,14 +92,24 @@ module.exports = class WyzeSmartHome {
83
92
  }
84
93
 
85
94
  async runLoop() {
86
- const interval = this.config.refreshInterval || DEFAULT_REFRESH_INTERVAL
95
+ const baseInterval = this.config.refreshInterval || DEFAULT_REFRESH_INTERVAL
96
+ let failures = 0
97
+
87
98
  // eslint-disable-next-line no-constant-condition
88
99
  while (true) {
89
100
  try {
90
101
  await this.refreshDevices()
91
- } catch (e) { }
102
+ failures = 0
103
+ } catch (e) {
104
+ failures += 1
105
+ const message = e?.message || String(e)
106
+ this.log.error(`Refresh loop error: ${message}`)
107
+ }
92
108
 
93
- await delay(interval)
109
+ // simple backoff to avoid noisy retry loops on auth/network failures
110
+ const backoffMultiplier = Math.min(6, failures) // caps at 64x
111
+ const delayMs = baseInterval * (failures === 0 ? 1 : Math.pow(2, backoffMultiplier))
112
+ await delay(delayMs)
94
113
  }
95
114
  }
96
115
 
@@ -102,10 +121,10 @@ module.exports = class WyzeSmartHome {
102
121
  const timestamp = objectList.ts
103
122
  const devices = objectList.data.device_list
104
123
 
105
- if (this.config.pluginLoggingEnabled) this.log(`Found ${devices.length} device(s)`)
124
+ if (this.config.pluginLoggingEnabled) this.log(`Found ${devices.length} device(s)`)
106
125
  await this.loadDevices(devices, timestamp)
107
126
  } catch (e) {
108
- this.log.error(`Error getting devices: ${e}`)
127
+ this.log.error(`Error getting devices: ${e?.message || e}`)
109
128
  throw e
110
129
  }
111
130
  }
@@ -122,7 +141,7 @@ module.exports = class WyzeSmartHome {
122
141
 
123
142
  const removedAccessories = this.accessories.filter(a => !foundAccessories.includes(a))
124
143
  if (removedAccessories.length > 0) {
125
- if (this.config.pluginLoggingEnabled) this.log(`Removing ${removedAccessories.length} device(s)`)
144
+ if (this.config.pluginLoggingEnabled) this.log(`Removing ${removedAccessories.length} device(s)`)
126
145
  const removedHomeKitAccessories = removedAccessories.map(a => a.homeKitAccessory)
127
146
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, removedHomeKitAccessories)
128
147
  }
@@ -131,28 +150,30 @@ module.exports = class WyzeSmartHome {
131
150
  }
132
151
 
133
152
  async loadDevice(device, timestamp) {
134
- const accessoryClass = this.getAccessoryClass(device.product_type, device.product_model, device.mac, device.nickname)
153
+ const safeNickname = sanitizeDeviceName(device.nickname)
154
+
155
+ const accessoryClass = this.getAccessoryClass(device.product_type, device.product_model, device.mac, safeNickname)
135
156
  if (!accessoryClass) {
136
- if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Unsupported device type: (Name: ${device.nickname}) (MAC: ${device.mac}) (Model: ${device.product_model})`)
157
+ if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Unsupported device type: (Name: ${safeNickname}) (MAC: ${device.mac}) (Model: ${device.product_model})`)
137
158
  return
138
159
  }
139
160
  else if (this.config.filterByMacAddressList?.find(d => d === device.mac) || this.config.filterDeviceTypeList?.find(d => d === device.product_type)) {
140
- if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${device.nickname}) (MAC: ${device.mac}) because it is in the Ignore Device list`)
161
+ if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${safeNickname}) (MAC: ${device.mac}) because it is in the Ignore Device list`)
141
162
  return
142
163
  }
143
164
  else if (device.product_type == 'S1Gateway' && this.config.hms == false) {
144
- if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${device.nickname}) (MAC: ${device.mac}) because it is not enabled`)
165
+ if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${safeNickname}) (MAC: ${device.mac}) because it is not enabled`)
145
166
  return
146
167
  }
147
168
 
148
169
 
149
170
  let accessory = this.accessories.find(a => a.matches(device))
150
171
  if (!accessory) {
151
- const homeKitAccessory = this.createHomeKitAccessory(device)
172
+ const homeKitAccessory = this.createHomeKitAccessory(device, safeNickname)
152
173
  accessory = new accessoryClass(this, homeKitAccessory)
153
174
  this.accessories.push(accessory)
154
175
  } else {
155
- if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Loading accessory from cache ${device.nickname} (MAC: ${device.mac})`)
176
+ if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Loading accessory from cache ${safeNickname} (MAC: ${device.mac})`)
156
177
  }
157
178
  accessory.update(device, timestamp)
158
179
 
@@ -192,16 +213,16 @@ module.exports = class WyzeSmartHome {
192
213
  }
193
214
  }
194
215
 
195
- createHomeKitAccessory(device) {
216
+ createHomeKitAccessory(device, safeNickname) {
196
217
  const uuid = UUIDGen.generate(device.mac)
197
218
 
198
- const homeKitAccessory = new Accessory(device.nickname, uuid)
219
+ const homeKitAccessory = new Accessory(safeNickname, uuid)
199
220
 
200
221
  homeKitAccessory.context = {
201
222
  mac: device.mac,
202
223
  product_type: device.product_type,
203
224
  product_model: device.product_model,
204
- nickname: device.nickname
225
+ nickname: safeNickname
205
226
  }
206
227
 
207
228
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homeKitAccessory])
@@ -224,7 +245,8 @@ module.exports = class WyzeSmartHome {
224
245
  try {
225
246
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homeKitAccessory])
226
247
  } catch (error) {
227
- this.log.error(`Error removing accessory ${homeKitAccessory.context.nickname} (MAC: ${homeKitAccessory.context.mac}) : ${error}`)
248
+ const safeName = sanitizeDeviceName(homeKitAccessory.context.nickname)
249
+ this.log.error(`Error removing accessory ${safeName} (MAC: ${homeKitAccessory.context.mac}) : ${error?.message || error}`)
228
250
  }
229
251
  }
230
252
  }
@@ -1,4 +1,5 @@
1
1
  const { Service, Characteristic } = require("../types");
2
+ const { sanitizeDeviceName } = require("../security");
2
3
 
3
4
  // Responses from the Wyze API can lag a little after a new value is set
4
5
  const UPDATE_THROTTLE_MS = 1000;
@@ -33,6 +34,7 @@ module.exports = class WyzeAccessory {
33
34
 
34
35
  async update(device, timestamp) {
35
36
  const productType = device.product_type;
37
+ const safeNickname = sanitizeDeviceName(device.nickname);
36
38
 
37
39
  switch (productType) {
38
40
  default:
@@ -40,14 +42,14 @@ module.exports = class WyzeAccessory {
40
42
  mac: device.mac,
41
43
  product_type: device.product_type,
42
44
  product_model: device.product_model,
43
- nickname: device.nickname,
45
+ nickname: safeNickname,
44
46
  };
45
47
  break;
46
48
  }
47
49
 
48
50
  this.homeKitAccessory
49
51
  .getService(Service.AccessoryInformation)
50
- .updateCharacteristic(Characteristic.Name, device.nickname)
52
+ .updateCharacteristic(Characteristic.Name, safeNickname)
51
53
  .updateCharacteristic(Characteristic.Manufacturer, "Wyze")
52
54
  .updateCharacteristic(Characteristic.Model, device.product_model)
53
55
  .updateCharacteristic(Characteristic.SerialNumber, device.mac)
@@ -82,4 +84,4 @@ module.exports = class WyzeAccessory {
82
84
  sleep(ms) {
83
85
  return new Promise((resolve) => setTimeout(resolve, ms * 1000));
84
86
  }
85
- };
87
+ };
@@ -81,7 +81,12 @@ module.exports = class WyzeMeshLight extends WyzeAccessory {
81
81
  ) {
82
82
  return true;
83
83
  } else {
84
- this.plugin.log(`Encountered invalid property value: ${JSON.stringify(property, null, 2)}`);
84
+ // Avoid logging raw API objects; they can contain device IDs / other sensitive fields.
85
+ if (this.plugin.config.pluginLoggingEnabled) {
86
+ this.plugin.log(
87
+ `Encountered invalid property value (pid=${property?.pid}, value=${String(property?.value)})`
88
+ );
89
+ }
85
90
  return false;
86
91
  }
87
92
  }
@@ -288,4 +293,4 @@ module.exports = class WyzeMeshLight extends WyzeAccessory {
288
293
  }
289
294
  }
290
295
  }
291
- };
296
+ };
@@ -0,0 +1,262 @@
1
+ const fs = require('fs')
2
+ const net = require('net')
3
+
4
+ const DEFAULT_AUTH_BASE_URL = 'https://auth-prod.api.wyze.com'
5
+ const DEFAULT_API_BASE_URL = 'https://api.wyzecam.com'
6
+
7
+ const WYZE_ALLOWED_HOSTNAMES = new Set([
8
+ new URL(DEFAULT_AUTH_BASE_URL).hostname,
9
+ new URL(DEFAULT_API_BASE_URL).hostname
10
+ ])
11
+
12
+ const SECRET_KEYS = [
13
+ 'username',
14
+ 'password',
15
+ 'mfaCode',
16
+ 'keyId',
17
+ 'apiKey',
18
+ 'authApiKey',
19
+ 'phoneId',
20
+ 'appName',
21
+ 'appVer',
22
+ 'appVersion',
23
+ 'userAgent',
24
+ 'sc',
25
+ 'sv',
26
+ 'fordAppKey',
27
+ 'fordAppSecret',
28
+ 'oliveSigningSecret',
29
+ 'oliveAppId',
30
+ 'appInfo'
31
+ ]
32
+
33
+ const ENV_MAP = {
34
+ username: 'WYZE_USERNAME',
35
+ password: 'WYZE_PASSWORD',
36
+ mfaCode: 'WYZE_MFA_CODE',
37
+ keyId: 'WYZE_KEY_ID',
38
+ apiKey: 'WYZE_API_KEY',
39
+ authApiKey: 'WYZE_AUTH_API_KEY',
40
+ phoneId: 'WYZE_PHONE_ID',
41
+ appName: 'WYZE_APP_NAME',
42
+ appVer: 'WYZE_APP_VER',
43
+ appVersion: 'WYZE_APP_VERSION',
44
+ userAgent: 'WYZE_USER_AGENT',
45
+ sc: 'WYZE_SC',
46
+ sv: 'WYZE_SV',
47
+ fordAppKey: 'WYZE_FORD_APP_KEY',
48
+ fordAppSecret: 'WYZE_FORD_APP_SECRET',
49
+ oliveSigningSecret: 'WYZE_OLIVE_SIGNING_SECRET',
50
+ oliveAppId: 'WYZE_OLIVE_APP_ID',
51
+ appInfo: 'WYZE_APP_INFO'
52
+ }
53
+
54
+ function stripControlChars(input) {
55
+ return String(input)
56
+ .replace(/[\r\n\t]+/g, ' ')
57
+ .replace(/[\u0000-\u001F\u007F]/g, '')
58
+ }
59
+
60
+ function boundLength(input, maxLen) {
61
+ const s = String(input)
62
+ if (s.length <= maxLen) return s
63
+ return s.slice(0, maxLen) + '…'
64
+ }
65
+
66
+ function redactMacs(str) {
67
+ return str.replace(/\b([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b/g, (mac) => {
68
+ const parts = mac.split(':')
69
+ return 'xx:xx:xx:xx:xx:' + parts[5]
70
+ })
71
+ }
72
+
73
+ function redactBearerTokens(str) {
74
+ return str.replace(/\bBearer\s+[-._~+/0-9a-zA-Z]+=*\b/g, 'Bearer [REDACTED]')
75
+ }
76
+
77
+ function redactKeyValueSecrets(str) {
78
+ const keys = [
79
+ 'password',
80
+ 'apiKey',
81
+ 'keyId',
82
+ 'access_token',
83
+ 'refresh_token',
84
+ 'accessToken',
85
+ 'refreshToken',
86
+ 'authorization',
87
+ 'fordAppSecret',
88
+ 'fordAppKey',
89
+ 'oliveSigningSecret',
90
+ 'oliveAppId',
91
+ 'authApiKey'
92
+ ]
93
+
94
+ const keyGroup = keys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
95
+
96
+ // Covers JSON-ish and plain logs like `password: ...` or `"password":"..."`
97
+ const re = new RegExp(`(\\b(?:${keyGroup})\\b"?\\s*[:=]\\s*)("?)([^"\\s,}]+)("?)`, 'gi')
98
+ return str.replace(re, (_m, prefix) => `${prefix}[REDACTED]`)
99
+ }
100
+
101
+ function sanitizeLogMessage(input) {
102
+ let s = stripControlChars(input)
103
+ s = redactBearerTokens(s)
104
+ s = redactKeyValueSecrets(s)
105
+ s = redactMacs(s)
106
+ return boundLength(s, 2000)
107
+ }
108
+
109
+ function sanitizeDeviceName(name) {
110
+ return boundLength(stripControlChars(name).trim(), 64) || 'Wyze Device'
111
+ }
112
+
113
+ function wrapLogger(log) {
114
+ const callLevel = (level, args) => {
115
+ const sanitizedArgs = args.map(sanitizeLogMessage)
116
+
117
+ if (log && typeof log[level] === 'function') {
118
+ return log[level](...sanitizedArgs)
119
+ }
120
+
121
+ if (typeof log === 'function') {
122
+ return log(...sanitizedArgs)
123
+ }
124
+ }
125
+
126
+ const wrapped = (...args) => callLevel('info', args)
127
+
128
+ for (const level of ['error', 'warn', 'info', 'debug']) {
129
+ wrapped[level] = (...args) => callLevel(level, args)
130
+ }
131
+
132
+ return wrapped
133
+ }
134
+
135
+ function loadSecretsFromFile(secretsFile, log) {
136
+ const stat = fs.statSync(secretsFile)
137
+ // Must not be readable/writable by group/others
138
+ if ((stat.mode & 0o077) !== 0) {
139
+ throw new Error(
140
+ `Secrets file permissions too open: ${secretsFile}. ` +
141
+ 'Set mode to 600 (owner read/write only).'
142
+ )
143
+ }
144
+
145
+ const raw = fs.readFileSync(secretsFile, 'utf8')
146
+ const parsed = JSON.parse(raw)
147
+ return parsed && typeof parsed === 'object' ? parsed : {}
148
+ }
149
+
150
+ function resolveSecrets(config, log) {
151
+ const merged = { ...config }
152
+
153
+ if (config?.secretsFile) {
154
+ try {
155
+ const fromFile = loadSecretsFromFile(config.secretsFile, log)
156
+ for (const k of SECRET_KEYS) {
157
+ if (fromFile[k] != null && fromFile[k] !== '') merged[k] = fromFile[k]
158
+ }
159
+ log('Loaded secrets from secretsFile')
160
+ } catch (e) {
161
+ // Refuse to start: this prevents accidentally using a world-readable secrets file.
162
+ log.error(`Failed to load secretsFile: ${e?.message || e}`)
163
+ throw e
164
+ }
165
+ }
166
+
167
+ for (const [key, envName] of Object.entries(ENV_MAP)) {
168
+ const v = process.env[envName]
169
+ if (v == null || v === '') continue
170
+
171
+ if (key === 'appInfo') {
172
+ try {
173
+ merged.appInfo = JSON.parse(v)
174
+ } catch {
175
+ merged.appInfo = v
176
+ }
177
+ continue
178
+ }
179
+
180
+ merged[key] = v
181
+ }
182
+
183
+ return merged
184
+ }
185
+
186
+ function isIpLiteral(hostname) {
187
+ return net.isIP(hostname) !== 0
188
+ }
189
+
190
+ function isClearlyLocalHostname(hostname) {
191
+ const lower = String(hostname).toLowerCase()
192
+ return (
193
+ lower === 'localhost' ||
194
+ lower.endsWith('.localhost') ||
195
+ lower.endsWith('.local')
196
+ )
197
+ }
198
+
199
+ function validateBaseUrl(urlString) {
200
+ const u = new URL(urlString)
201
+ if (u.protocol !== 'https:') {
202
+ throw new Error(`Base URL must use https: ${urlString}`)
203
+ }
204
+ if (isIpLiteral(u.hostname)) {
205
+ throw new Error(`IP literals are not allowed in base URLs: ${urlString}`)
206
+ }
207
+ if (isClearlyLocalHostname(u.hostname)) {
208
+ throw new Error(`Local hostnames are not allowed in base URLs: ${urlString}`)
209
+ }
210
+ return u
211
+ }
212
+
213
+ function getValidatedBaseUrls(config, log) {
214
+ const hasCustom = Boolean(config?.authBaseUrl || config?.apiBaseUrl)
215
+ const allowCustom = Boolean(config?.dangerouslyAllowCustomBaseUrls)
216
+
217
+ if (hasCustom && !allowCustom) {
218
+ log.error(
219
+ 'Custom authBaseUrl/apiBaseUrl are ignored for security. ' +
220
+ 'If you understand the risks and still want this, set dangerouslyAllowCustomBaseUrls=true.'
221
+ )
222
+ return {
223
+ authBaseUrl: DEFAULT_AUTH_BASE_URL,
224
+ apiBaseUrl: DEFAULT_API_BASE_URL
225
+ }
226
+ }
227
+
228
+ if (!hasCustom) {
229
+ return {
230
+ authBaseUrl: DEFAULT_AUTH_BASE_URL,
231
+ apiBaseUrl: DEFAULT_API_BASE_URL
232
+ }
233
+ }
234
+
235
+ // Custom base URLs only when explicitly opted-in.
236
+ const authUrl = config.authBaseUrl ? validateBaseUrl(config.authBaseUrl) : new URL(DEFAULT_AUTH_BASE_URL)
237
+ const apiUrl = config.apiBaseUrl ? validateBaseUrl(config.apiBaseUrl) : new URL(DEFAULT_API_BASE_URL)
238
+
239
+ // Even when opted-in, refuse non-Wyze hosts by default.
240
+ // This keeps the escape-hatch useful for path tweaks, but blocks credential exfiltration/SSRF.
241
+ if (!WYZE_ALLOWED_HOSTNAMES.has(authUrl.hostname) || !WYZE_ALLOWED_HOSTNAMES.has(apiUrl.hostname)) {
242
+ throw new Error(
243
+ `Custom base URL hostnames must be one of: ${Array.from(WYZE_ALLOWED_HOSTNAMES).join(', ')}. ` +
244
+ 'Refusing to start.'
245
+ )
246
+ }
247
+
248
+ return {
249
+ authBaseUrl: authUrl.toString().replace(/\/+$/, ''),
250
+ apiBaseUrl: apiUrl.toString().replace(/\/+$/, '')
251
+ }
252
+ }
253
+
254
+ module.exports = {
255
+ DEFAULT_AUTH_BASE_URL,
256
+ DEFAULT_API_BASE_URL,
257
+ sanitizeLogMessage,
258
+ sanitizeDeviceName,
259
+ wrapLogger,
260
+ resolveSecrets,
261
+ getValidatedBaseUrls
262
+ }