i18ntk 3.1.2 → 3.3.0
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/CHANGELOG.md +94 -23
- package/README.md +29 -23
- package/SECURITY.md +19 -5
- package/main/i18ntk-complete.js +120 -49
- package/main/i18ntk-translate.js +25 -1
- package/main/manage/index.js +0 -2
- package/main/manage/managers/InteractiveMenu.js +4 -0
- package/main/manage/services/FileManagementService.js +14 -11
- package/package.json +5 -3
- package/runtime/enhanced.d.ts +1 -1
- package/runtime/i18ntk.d.ts +2 -11
- package/ui-locales/de.json +1389 -1359
- package/ui-locales/es.json +1503 -1473
- package/ui-locales/fr.json +1626 -1596
- package/ui-locales/ja.json +1595 -1565
- package/ui-locales/ru.json +1638 -1608
- package/ui-locales/zh.json +1613 -1583
- package/utils/admin-auth.js +6 -6
- package/utils/config-manager.js +6 -3
- package/utils/translate/api.js +172 -41
- package/utils/translate/safe-network.js +280 -0
- package/utils/translate/traverse.js +14 -25
package/utils/admin-auth.js
CHANGED
|
@@ -544,13 +544,13 @@ class AdminAuth {
|
|
|
544
544
|
config.lastModified = new Date().toISOString();
|
|
545
545
|
const success = await this.saveConfig(config);
|
|
546
546
|
if (success) {
|
|
547
|
-
SecurityUtils.logSecurityEvent('pin_protection_disabled', 'info', 'PIN protection disabled (PIN retained)');
|
|
547
|
+
SecurityUtils.logSecurityEvent('pin_protection_disabled', 'info', { message: 'PIN protection disabled (PIN retained)' });
|
|
548
548
|
}
|
|
549
549
|
return success;
|
|
550
550
|
}
|
|
551
551
|
return true;
|
|
552
552
|
} catch (error) {
|
|
553
|
-
SecurityUtils.logSecurityEvent('pin_protection_disable_error', 'error', `Failed to disable PIN protection: ${error.message}`);
|
|
553
|
+
SecurityUtils.logSecurityEvent('pin_protection_disable_error', 'error', { message: `Failed to disable PIN protection: ${error.message}` });
|
|
554
554
|
return false;
|
|
555
555
|
}
|
|
556
556
|
}
|
|
@@ -566,13 +566,13 @@ class AdminAuth {
|
|
|
566
566
|
config.lastModified = new Date().toISOString();
|
|
567
567
|
const success = await this.saveConfig(config);
|
|
568
568
|
if (success) {
|
|
569
|
-
SecurityUtils.logSecurityEvent('pin_protection_enabled', 'info', 'PIN protection enabled');
|
|
569
|
+
SecurityUtils.logSecurityEvent('pin_protection_enabled', 'info', { message: 'PIN protection enabled' });
|
|
570
570
|
}
|
|
571
571
|
return success;
|
|
572
572
|
}
|
|
573
573
|
return false;
|
|
574
574
|
} catch (error) {
|
|
575
|
-
SecurityUtils.logSecurityEvent('pin_protection_enable_error', 'error', `Failed to enable PIN protection: ${error.message}`);
|
|
575
|
+
SecurityUtils.logSecurityEvent('pin_protection_enable_error', 'error', { message: `Failed to enable PIN protection: ${error.message}` });
|
|
576
576
|
return false;
|
|
577
577
|
}
|
|
578
578
|
}
|
|
@@ -618,7 +618,7 @@ class AdminAuth {
|
|
|
618
618
|
destroySession(sessionId) {
|
|
619
619
|
const deleted = this.activeSessions.delete(sessionId);
|
|
620
620
|
if (deleted) {
|
|
621
|
-
SecurityUtils.logSecurityEvent('admin_session_destroyed', 'info', 'Admin session destroyed');
|
|
621
|
+
SecurityUtils.logSecurityEvent('admin_session_destroyed', 'info', { message: 'Admin session destroyed' });
|
|
622
622
|
}
|
|
623
623
|
return deleted;
|
|
624
624
|
}
|
|
@@ -643,7 +643,7 @@ class AdminAuth {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
if (cleaned > 0) {
|
|
646
|
-
SecurityUtils.logSecurityEvent('admin_sessions_cleaned', 'info', `Cleaned up ${cleaned} expired sessions`);
|
|
646
|
+
SecurityUtils.logSecurityEvent('admin_sessions_cleaned', 'info', { message: `Cleaned up ${cleaned} expired sessions` });
|
|
647
647
|
}
|
|
648
648
|
}
|
|
649
649
|
|
package/utils/config-manager.js
CHANGED
|
@@ -720,9 +720,10 @@ async function setConfig(cfg) {
|
|
|
720
720
|
|
|
721
721
|
async function updateConfig(patch) {
|
|
722
722
|
const cfg = loadConfig();
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
723
|
+
const cloned = clone(cfg);
|
|
724
|
+
deepMerge(cloned, patch);
|
|
725
|
+
currentConfig = cloned;
|
|
726
|
+
return cloned;
|
|
726
727
|
}
|
|
727
728
|
|
|
728
729
|
async function resetToDefaults() {
|
|
@@ -764,6 +765,8 @@ module.exports = {
|
|
|
764
765
|
DEFAULT_CONFIG,
|
|
765
766
|
loadConfig,
|
|
766
767
|
saveConfig,
|
|
768
|
+
loadSettings: loadConfig,
|
|
769
|
+
saveSettings: saveConfig,
|
|
767
770
|
getConfig,
|
|
768
771
|
updateConfig,
|
|
769
772
|
setConfig,
|
package/utils/translate/api.js
CHANGED
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
const https = require('https');
|
|
2
|
-
const http = require('http');
|
|
3
1
|
const { URL } = require('url');
|
|
2
|
+
const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
|
|
4
3
|
|
|
5
4
|
const DEFAULT_CONCURRENCY = 3;
|
|
6
5
|
const DEFAULT_RETRY_COUNT = 3;
|
|
7
6
|
const DEFAULT_RETRY_DELAY = 1000;
|
|
8
7
|
const MAX_BACKOFF_DELAY = 30000;
|
|
9
8
|
|
|
10
|
-
function httpGet(urlString, timeout = 15000) {
|
|
11
|
-
return new Promise((resolve) => {
|
|
12
|
-
const url = new URL(urlString);
|
|
13
|
-
const client = url.protocol === 'https:' ? https : http;
|
|
14
|
-
const req = client.get(urlString, { timeout }, (res) => {
|
|
15
|
-
let data = '';
|
|
16
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
17
|
-
res.on('end', () => {
|
|
18
|
-
try {
|
|
19
|
-
const parsed = JSON.parse(data);
|
|
20
|
-
resolve({ ok: true, data: parsed, status: res.statusCode });
|
|
21
|
-
} catch (e) {
|
|
22
|
-
resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
req.on('error', (e) => {
|
|
27
|
-
resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
|
|
28
|
-
});
|
|
29
|
-
req.on('timeout', () => {
|
|
30
|
-
req.destroy();
|
|
31
|
-
resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
9
|
|
|
36
10
|
function extractTranslation(result) {
|
|
37
11
|
if (result && Array.isArray(result) && result[0]) {
|
|
@@ -44,6 +18,154 @@ function extractTranslation(result) {
|
|
|
44
18
|
return null;
|
|
45
19
|
}
|
|
46
20
|
|
|
21
|
+
function normalizeProvider(provider) {
|
|
22
|
+
const value = String(provider || process.env.I18NTK_TRANSLATE_PROVIDER || 'google').trim().toLowerCase();
|
|
23
|
+
if (value === 'deepl-free' || value === 'deepl-pro') return 'deepl';
|
|
24
|
+
if (value === 'libre' || value === 'libretranslate') return 'libretranslate';
|
|
25
|
+
if (value === 'google' || value === 'gtx') return 'google';
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDeepLLanguage(code) {
|
|
30
|
+
return String(code || '').replace('-', '_').toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getDeepLApiUrl(options = {}) {
|
|
34
|
+
if (options.deeplApiUrl) return options.deeplApiUrl;
|
|
35
|
+
if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL;
|
|
36
|
+
return 'https://api-free.deepl.com/v2/translate';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isEnabled(value) {
|
|
40
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getDeepLAllowedHosts(url, options = {}) {
|
|
44
|
+
const hosts = ['api-free.deepl.com', 'api.deepl.com'];
|
|
45
|
+
if (options.allowCustomTranslateHosts || isEnabled(process.env.I18NTK_ALLOW_CUSTOM_TRANSLATE_HOSTS)) {
|
|
46
|
+
hosts.push(new URL(url).hostname);
|
|
47
|
+
}
|
|
48
|
+
return [...new Set(hosts)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseProviderUrl(url, provider) {
|
|
52
|
+
try {
|
|
53
|
+
return { ok: true, parsed: new URL(url) };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: 'InvalidProviderUrl',
|
|
58
|
+
message: `${provider} provider URL is invalid: ${error.message}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractDeepLTranslation(data) {
|
|
64
|
+
const value = data && Array.isArray(data.translations) && data.translations[0] && data.translations[0].text;
|
|
65
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getLibreTranslateUrl(options = {}) {
|
|
69
|
+
if (options.libreTranslateUrl) return options.libreTranslateUrl;
|
|
70
|
+
if (process.env.LIBRETRANSLATE_URL) return process.env.LIBRETRANSLATE_URL;
|
|
71
|
+
return 'https://libretranslate.com/translate';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getLibreTranslateAllowedHosts(url) {
|
|
75
|
+
return [...new Set(['libretranslate.com', new URL(url).hostname])];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractLibreTranslateTranslation(data) {
|
|
79
|
+
const value = data && data.translatedText;
|
|
80
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildProviderRequest(text, targetLang, options = {}) {
|
|
84
|
+
const provider = normalizeProvider(options.provider);
|
|
85
|
+
const sourceLang = options.sourceLang || 'en';
|
|
86
|
+
|
|
87
|
+
if (provider === 'google') {
|
|
88
|
+
return {
|
|
89
|
+
provider,
|
|
90
|
+
method: 'GET',
|
|
91
|
+
url: buildGoogleTranslateUrl(text, sourceLang, targetLang),
|
|
92
|
+
extract: extractTranslation,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (provider === 'deepl') {
|
|
97
|
+
const apiKey = options.deeplApiKey || process.env.DEEPL_API_KEY;
|
|
98
|
+
if (!apiKey) {
|
|
99
|
+
return {
|
|
100
|
+
provider,
|
|
101
|
+
error: 'MissingApiKey',
|
|
102
|
+
message: 'DeepL provider requires DEEPL_API_KEY in the environment or deeplApiKey in options.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const url = getDeepLApiUrl(options);
|
|
107
|
+
const parsedUrl = parseProviderUrl(url, 'DeepL');
|
|
108
|
+
if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
provider,
|
|
112
|
+
method: 'POST',
|
|
113
|
+
url,
|
|
114
|
+
body: {
|
|
115
|
+
text: [text],
|
|
116
|
+
target_lang: normalizeDeepLLanguage(targetLang),
|
|
117
|
+
source_lang: normalizeDeepLLanguage(sourceLang),
|
|
118
|
+
},
|
|
119
|
+
requestOptions: {
|
|
120
|
+
provider,
|
|
121
|
+
allowedHosts: getDeepLAllowedHosts(url, options),
|
|
122
|
+
allowedPaths: ['/v2/translate'],
|
|
123
|
+
headers: {
|
|
124
|
+
Authorization: `DeepL-Auth-Key ${apiKey}`,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
extract: extractDeepLTranslation,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (provider === 'libretranslate') {
|
|
132
|
+
const apiKey = options.libreTranslateApiKey || process.env.LIBRETRANSLATE_API_KEY || '';
|
|
133
|
+
const url = getLibreTranslateUrl(options);
|
|
134
|
+
const parsedUrl = parseProviderUrl(url, 'LibreTranslate');
|
|
135
|
+
if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
|
|
136
|
+
|
|
137
|
+
const params = new URLSearchParams({
|
|
138
|
+
q: text,
|
|
139
|
+
source: sourceLang,
|
|
140
|
+
target: targetLang,
|
|
141
|
+
format: 'text',
|
|
142
|
+
});
|
|
143
|
+
if (apiKey) params.set('api_key', apiKey);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
provider,
|
|
147
|
+
method: 'POST',
|
|
148
|
+
url,
|
|
149
|
+
body: params.toString(),
|
|
150
|
+
requestOptions: {
|
|
151
|
+
provider,
|
|
152
|
+
allowedHosts: getLibreTranslateAllowedHosts(url),
|
|
153
|
+
allowedPaths: ['/translate'],
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
extract: extractLibreTranslateTranslation,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
provider,
|
|
164
|
+
error: 'UnsupportedProvider',
|
|
165
|
+
message: `Unsupported translation provider "${provider}". Use google, deepl, or libretranslate.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
47
169
|
function detectRateLimitError(result) {
|
|
48
170
|
if (!result.ok && result.status === 429) return true;
|
|
49
171
|
if (result.ok && result.status === 429) return true;
|
|
@@ -59,6 +181,8 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
59
181
|
retryDelay = DEFAULT_RETRY_DELAY,
|
|
60
182
|
customFn,
|
|
61
183
|
timeout = 15000,
|
|
184
|
+
httpGet = safeHttpGet,
|
|
185
|
+
httpPost = safeHttpPost,
|
|
62
186
|
} = options;
|
|
63
187
|
|
|
64
188
|
if (!text || text.trim().length === 0) return { ok: true, translated: text };
|
|
@@ -72,14 +196,10 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
72
196
|
}
|
|
73
197
|
}
|
|
74
198
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
dt: 't',
|
|
80
|
-
q: text,
|
|
81
|
-
});
|
|
82
|
-
const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
|
|
199
|
+
const request = buildProviderRequest(text, targetLang, { ...options, sourceLang });
|
|
200
|
+
if (request.error) {
|
|
201
|
+
return { ok: false, translated: null, error: request.error, message: request.message };
|
|
202
|
+
}
|
|
83
203
|
|
|
84
204
|
let lastError = null;
|
|
85
205
|
for (let attempt = 0; attempt < retryCount; attempt++) {
|
|
@@ -88,18 +208,20 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
88
208
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
89
209
|
}
|
|
90
210
|
|
|
91
|
-
const result =
|
|
211
|
+
const result = request.method === 'POST'
|
|
212
|
+
? await httpPost(request.url, request.body, { ...(request.requestOptions || {}), timeout })
|
|
213
|
+
: await httpGet(request.url, timeout);
|
|
92
214
|
|
|
93
215
|
if (result.ok) {
|
|
94
|
-
const translated =
|
|
216
|
+
const translated = request.extract(result.data);
|
|
95
217
|
if (translated !== null && translated !== text) {
|
|
96
218
|
return { ok: true, translated };
|
|
97
219
|
}
|
|
98
220
|
if (translated === text) {
|
|
99
221
|
return { ok: true, translated: text };
|
|
100
222
|
}
|
|
101
|
-
if (result.status === 429) {
|
|
102
|
-
lastError = { error: 'RateLimited', message:
|
|
223
|
+
if (result.status === 429 || (translated === null && result.status >= 400)) {
|
|
224
|
+
lastError = { error: 'RateLimited', message: `${request.provider} rate limit hit` };
|
|
103
225
|
continue;
|
|
104
226
|
}
|
|
105
227
|
}
|
|
@@ -109,6 +231,11 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
109
231
|
continue;
|
|
110
232
|
}
|
|
111
233
|
|
|
234
|
+
if (result.error === 'TimeoutError' || result.error === 'NetworkError') {
|
|
235
|
+
lastError = { error: result.error, message: result.message || 'Network request failed, retrying' };
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
112
239
|
lastError = { error: result.error || 'UnknownError', message: result.message || 'Request failed' };
|
|
113
240
|
}
|
|
114
241
|
|
|
@@ -149,16 +276,20 @@ async function translateBatch(batch, targetLang, options = {}) {
|
|
|
149
276
|
}
|
|
150
277
|
}
|
|
151
278
|
|
|
152
|
-
const workerCount = Math.min(concurrency, batch.length);
|
|
279
|
+
const workerCount = Math.min(concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY, batch.length);
|
|
153
280
|
const workers = Array.from({ length: workerCount }, () => worker());
|
|
281
|
+
|
|
154
282
|
await Promise.all(workers);
|
|
155
283
|
|
|
156
284
|
return results;
|
|
157
285
|
}
|
|
158
286
|
|
|
159
287
|
module.exports = {
|
|
160
|
-
httpGet,
|
|
161
288
|
extractTranslation,
|
|
289
|
+
extractDeepLTranslation,
|
|
290
|
+
extractLibreTranslateTranslation,
|
|
291
|
+
buildProviderRequest,
|
|
292
|
+
normalizeProvider,
|
|
162
293
|
detectRateLimitError,
|
|
163
294
|
translateText,
|
|
164
295
|
translateBatch,
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const { URL } = require('url');
|
|
3
|
+
const { logger } = require('../logger');
|
|
4
|
+
|
|
5
|
+
const MAX_RESPONSE_SIZE = 100 * 1024;
|
|
6
|
+
const ALLOWED_HOSTS = ['translate.googleapis.com', 'api-free.deepl.com', 'api.deepl.com', 'libretranslate.com'];
|
|
7
|
+
const ALLOWED_PATHS = ['/translate_a/single', '/v2/translate', '/translate'];
|
|
8
|
+
const USER_AGENT = 'i18ntk/3.3.0';
|
|
9
|
+
|
|
10
|
+
function isEnabled(value) {
|
|
11
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPrivateIPv4(hostname) {
|
|
15
|
+
const parts = String(hostname || '').split('.');
|
|
16
|
+
if (parts.length !== 4) return false;
|
|
17
|
+
|
|
18
|
+
const bytes = parts.map((part) => {
|
|
19
|
+
if (!/^\d+$/.test(part)) return null;
|
|
20
|
+
const value = Number(part);
|
|
21
|
+
return value >= 0 && value <= 255 ? value : null;
|
|
22
|
+
});
|
|
23
|
+
if (bytes.some((part) => part === null)) return false;
|
|
24
|
+
|
|
25
|
+
const [a, b] = bytes;
|
|
26
|
+
return a === 10
|
|
27
|
+
|| a === 127
|
|
28
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
29
|
+
|| (a === 192 && b === 168)
|
|
30
|
+
|| (a === 169 && b === 254)
|
|
31
|
+
|| a === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPrivateHost(hostname) {
|
|
35
|
+
const normalized = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
36
|
+
return normalized === 'localhost'
|
|
37
|
+
|| normalized.endsWith('.localhost')
|
|
38
|
+
|| normalized === '::1'
|
|
39
|
+
|| normalized.startsWith('fe80:')
|
|
40
|
+
|| normalized.startsWith('fc')
|
|
41
|
+
|| normalized.startsWith('fd')
|
|
42
|
+
|| isPrivateIPv4(normalized);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function redactUrlForLog(urlString) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(urlString);
|
|
48
|
+
parsed.search = '';
|
|
49
|
+
parsed.hash = '';
|
|
50
|
+
return parsed.toString();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return '[invalid-url]';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateUrl(urlString, options = {}) {
|
|
57
|
+
if (!urlString || typeof urlString !== 'string') {
|
|
58
|
+
return { valid: false, error: 'InvalidUrl', message: 'URL must be a non-empty string' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed = new URL(urlString);
|
|
63
|
+
|
|
64
|
+
if (parsed.protocol !== 'https:') {
|
|
65
|
+
return { valid: false, error: 'ProtocolError', message: 'Only HTTPS protocol is supported' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const allowPrivateHosts = options.allowPrivateHosts || isEnabled(process.env.I18NTK_ALLOW_PRIVATE_TRANSLATE_URLS);
|
|
69
|
+
if (!allowPrivateHosts && isPrivateHost(parsed.hostname)) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
error: 'PrivateHostNotAllowed',
|
|
73
|
+
message: `Host "${parsed.hostname}" is private or local. Set I18NTK_ALLOW_PRIVATE_TRANSLATE_URLS=1 only for trusted local testing.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const allowedHosts = options.allowedHosts || ALLOWED_HOSTS;
|
|
78
|
+
const allowedPaths = options.allowedPaths || ALLOWED_PATHS;
|
|
79
|
+
|
|
80
|
+
if (!allowedHosts.includes(parsed.hostname)) {
|
|
81
|
+
return { valid: false, error: 'HostNotAllowed', message: `Host "${parsed.hostname}" is not in the allowed list` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!allowedPaths.includes(parsed.pathname)) {
|
|
85
|
+
return { valid: false, error: 'PathNotAllowed', message: `Path "${parsed.pathname}" is not in the allowed list` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const suspiciousParams = ['redirect', 'callback', 'jsonp', 'script', 'src', 'eval', 'exec', 'url'];
|
|
89
|
+
for (const key of parsed.searchParams.keys()) {
|
|
90
|
+
const lower = key.toLowerCase();
|
|
91
|
+
if (suspiciousParams.some((s) => lower.includes(s))) {
|
|
92
|
+
return { valid: false, error: 'SuspiciousParam', message: `Query parameter "${key}" is not allowed` };
|
|
93
|
+
}
|
|
94
|
+
const value = parsed.searchParams.get(key);
|
|
95
|
+
if (value && (value.includes('<script') || value.includes('javascript:') || value.includes('data:'))) {
|
|
96
|
+
return { valid: false, error: 'SuspiciousParamValue', message: `Query parameter "${key}" contains potentially dangerous content` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { valid: true, url: parsed };
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return { valid: false, error: 'UrlParseError', message: `Failed to parse URL: ${e.message}` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function safeHttpGet(urlString, timeout = 15000) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const validation = validateUrl(urlString);
|
|
109
|
+
if (!validation.valid) {
|
|
110
|
+
logger.security('warn', 'Network request blocked by safe-network', {
|
|
111
|
+
url: redactUrlForLog(urlString),
|
|
112
|
+
reason: validation.error,
|
|
113
|
+
detail: validation.message,
|
|
114
|
+
});
|
|
115
|
+
resolve({ ok: false, error: validation.error, message: validation.message });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const req = https.get(urlString, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
|
|
120
|
+
let data = '';
|
|
121
|
+
let sizeExceeded = false;
|
|
122
|
+
|
|
123
|
+
res.on('data', (chunk) => {
|
|
124
|
+
if (data.length + chunk.length > MAX_RESPONSE_SIZE) {
|
|
125
|
+
if (!sizeExceeded) {
|
|
126
|
+
sizeExceeded = true;
|
|
127
|
+
req.destroy();
|
|
128
|
+
logger.security('warn', 'Network response size limit exceeded', {
|
|
129
|
+
url: redactUrlForLog(urlString),
|
|
130
|
+
limit: MAX_RESPONSE_SIZE,
|
|
131
|
+
});
|
|
132
|
+
resolve({ ok: false, error: 'ResponseTooLarge', message: `Response exceeds ${MAX_RESPONSE_SIZE} bytes limit` });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
data += chunk;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
res.on('end', () => {
|
|
140
|
+
if (sizeExceeded) return;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(data);
|
|
143
|
+
logger.security('info', 'Network request completed', {
|
|
144
|
+
status: res.statusCode,
|
|
145
|
+
responseLength: data.length,
|
|
146
|
+
});
|
|
147
|
+
resolve({ ok: true, data: parsed, status: res.statusCode });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
logger.security('warn', 'Network response parse error', {
|
|
150
|
+
status: res.statusCode,
|
|
151
|
+
responseLength: data.length,
|
|
152
|
+
});
|
|
153
|
+
resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
req.setTimeout(timeout, () => {
|
|
159
|
+
req.destroy();
|
|
160
|
+
logger.security('warn', 'Network request timed out', { timeoutMs: timeout });
|
|
161
|
+
resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
req.on('error', (e) => {
|
|
165
|
+
logger.security('warn', 'Network request error', {
|
|
166
|
+
code: e.code,
|
|
167
|
+
message: e.message,
|
|
168
|
+
});
|
|
169
|
+
resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safeHttpPost(urlString, body, options = {}) {
|
|
175
|
+
const timeout = options.timeout || 15000;
|
|
176
|
+
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const validation = validateUrl(urlString, {
|
|
179
|
+
allowedHosts: options.allowedHosts,
|
|
180
|
+
allowedPaths: options.allowedPaths,
|
|
181
|
+
allowPrivateHosts: options.allowPrivateHosts,
|
|
182
|
+
});
|
|
183
|
+
if (!validation.valid) {
|
|
184
|
+
logger.security('warn', 'Network request blocked by safe-network', {
|
|
185
|
+
url: redactUrlForLog(urlString),
|
|
186
|
+
reason: validation.error,
|
|
187
|
+
detail: validation.message,
|
|
188
|
+
});
|
|
189
|
+
resolve({ ok: false, error: validation.error, message: validation.message });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body || {});
|
|
194
|
+
const headers = {
|
|
195
|
+
'User-Agent': USER_AGENT,
|
|
196
|
+
'Content-Type': options.contentType || 'application/json',
|
|
197
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
198
|
+
...(options.headers || {}),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const req = https.request(validation.url, { method: 'POST', headers }, (res) => {
|
|
202
|
+
let data = '';
|
|
203
|
+
let sizeExceeded = false;
|
|
204
|
+
|
|
205
|
+
res.on('data', (chunk) => {
|
|
206
|
+
if (data.length + chunk.length > MAX_RESPONSE_SIZE) {
|
|
207
|
+
if (!sizeExceeded) {
|
|
208
|
+
sizeExceeded = true;
|
|
209
|
+
req.destroy();
|
|
210
|
+
logger.security('warn', 'Network response size limit exceeded', {
|
|
211
|
+
url: redactUrlForLog(urlString),
|
|
212
|
+
limit: MAX_RESPONSE_SIZE,
|
|
213
|
+
});
|
|
214
|
+
resolve({ ok: false, error: 'ResponseTooLarge', message: `Response exceeds ${MAX_RESPONSE_SIZE} bytes limit` });
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
data += chunk;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
res.on('end', () => {
|
|
222
|
+
if (sizeExceeded) return;
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(data || '{}');
|
|
225
|
+
logger.security('info', 'Network request completed', {
|
|
226
|
+
status: res.statusCode,
|
|
227
|
+
responseLength: data.length,
|
|
228
|
+
});
|
|
229
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, data: parsed, status: res.statusCode });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
logger.security('warn', 'Network response parse error', {
|
|
232
|
+
status: res.statusCode,
|
|
233
|
+
responseLength: data.length,
|
|
234
|
+
});
|
|
235
|
+
resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
req.setTimeout(timeout, () => {
|
|
241
|
+
req.destroy();
|
|
242
|
+
logger.security('warn', 'Network request timed out', { timeoutMs: timeout });
|
|
243
|
+
resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
req.on('error', (e) => {
|
|
247
|
+
logger.security('warn', 'Network request error', {
|
|
248
|
+
code: e.code,
|
|
249
|
+
message: e.message,
|
|
250
|
+
});
|
|
251
|
+
resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
req.write(payload);
|
|
255
|
+
req.end();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildGoogleTranslateUrl(text, sourceLang, targetLang) {
|
|
260
|
+
const params = new URLSearchParams({
|
|
261
|
+
client: 'gtx',
|
|
262
|
+
sl: sourceLang,
|
|
263
|
+
tl: targetLang,
|
|
264
|
+
dt: 't',
|
|
265
|
+
q: text,
|
|
266
|
+
});
|
|
267
|
+
return `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
safeHttpGet,
|
|
272
|
+
safeHttpPost,
|
|
273
|
+
buildGoogleTranslateUrl,
|
|
274
|
+
validateUrl,
|
|
275
|
+
isPrivateHost,
|
|
276
|
+
redactUrlForLog,
|
|
277
|
+
MAX_RESPONSE_SIZE,
|
|
278
|
+
ALLOWED_HOSTS,
|
|
279
|
+
ALLOWED_PATHS,
|
|
280
|
+
};
|