i18ntk 3.2.0 → 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 +34 -1
- package/README.md +28 -15
- package/SECURITY.md +19 -5
- package/main/i18ntk-complete.js +120 -49
- package/main/i18ntk-translate.js +25 -1
- package/package.json +3 -3
- 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/translate/api.js +164 -41
- package/utils/translate/safe-network.js +280 -0
package/utils/translate/api.js
CHANGED
|
@@ -1,39 +1,11 @@
|
|
|
1
|
-
const https = require('https');
|
|
2
1
|
const { URL } = require('url');
|
|
2
|
+
const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
|
|
3
3
|
|
|
4
4
|
const DEFAULT_CONCURRENCY = 3;
|
|
5
5
|
const DEFAULT_RETRY_COUNT = 3;
|
|
6
6
|
const DEFAULT_RETRY_DELAY = 1000;
|
|
7
7
|
const MAX_BACKOFF_DELAY = 30000;
|
|
8
8
|
|
|
9
|
-
function httpGet(urlString, timeout = 15000) {
|
|
10
|
-
return new Promise((resolve) => {
|
|
11
|
-
const url = new URL(urlString);
|
|
12
|
-
if (url.protocol !== 'https:') {
|
|
13
|
-
resolve({ ok: false, error: 'ProtocolError', message: 'Only HTTPS requests are supported' });
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
const req = https.get(urlString, { headers: { 'User-Agent': 'i18ntk/3.2.0' } }, (res) => {
|
|
17
|
-
let data = '';
|
|
18
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
19
|
-
res.on('end', () => {
|
|
20
|
-
try {
|
|
21
|
-
const parsed = JSON.parse(data);
|
|
22
|
-
resolve({ ok: true, data: parsed, status: res.statusCode });
|
|
23
|
-
} catch (e) {
|
|
24
|
-
resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
req.setTimeout(timeout, () => {
|
|
29
|
-
req.destroy();
|
|
30
|
-
resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
|
|
31
|
-
});
|
|
32
|
-
req.on('error', (e) => {
|
|
33
|
-
resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
9
|
|
|
38
10
|
function extractTranslation(result) {
|
|
39
11
|
if (result && Array.isArray(result) && result[0]) {
|
|
@@ -46,6 +18,154 @@ function extractTranslation(result) {
|
|
|
46
18
|
return null;
|
|
47
19
|
}
|
|
48
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
|
+
|
|
49
169
|
function detectRateLimitError(result) {
|
|
50
170
|
if (!result.ok && result.status === 429) return true;
|
|
51
171
|
if (result.ok && result.status === 429) return true;
|
|
@@ -61,6 +181,8 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
61
181
|
retryDelay = DEFAULT_RETRY_DELAY,
|
|
62
182
|
customFn,
|
|
63
183
|
timeout = 15000,
|
|
184
|
+
httpGet = safeHttpGet,
|
|
185
|
+
httpPost = safeHttpPost,
|
|
64
186
|
} = options;
|
|
65
187
|
|
|
66
188
|
if (!text || text.trim().length === 0) return { ok: true, translated: text };
|
|
@@ -74,14 +196,10 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
74
196
|
}
|
|
75
197
|
}
|
|
76
198
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
dt: 't',
|
|
82
|
-
q: text,
|
|
83
|
-
});
|
|
84
|
-
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
|
+
}
|
|
85
203
|
|
|
86
204
|
let lastError = null;
|
|
87
205
|
for (let attempt = 0; attempt < retryCount; attempt++) {
|
|
@@ -90,10 +208,12 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
90
208
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
91
209
|
}
|
|
92
210
|
|
|
93
|
-
const result =
|
|
211
|
+
const result = request.method === 'POST'
|
|
212
|
+
? await httpPost(request.url, request.body, { ...(request.requestOptions || {}), timeout })
|
|
213
|
+
: await httpGet(request.url, timeout);
|
|
94
214
|
|
|
95
215
|
if (result.ok) {
|
|
96
|
-
const translated =
|
|
216
|
+
const translated = request.extract(result.data);
|
|
97
217
|
if (translated !== null && translated !== text) {
|
|
98
218
|
return { ok: true, translated };
|
|
99
219
|
}
|
|
@@ -101,7 +221,7 @@ async function translateText(text, targetLang, options = {}) {
|
|
|
101
221
|
return { ok: true, translated: text };
|
|
102
222
|
}
|
|
103
223
|
if (result.status === 429 || (translated === null && result.status >= 400)) {
|
|
104
|
-
lastError = { error: 'RateLimited', message:
|
|
224
|
+
lastError = { error: 'RateLimited', message: `${request.provider} rate limit hit` };
|
|
105
225
|
continue;
|
|
106
226
|
}
|
|
107
227
|
}
|
|
@@ -165,8 +285,11 @@ async function translateBatch(batch, targetLang, options = {}) {
|
|
|
165
285
|
}
|
|
166
286
|
|
|
167
287
|
module.exports = {
|
|
168
|
-
httpGet,
|
|
169
288
|
extractTranslation,
|
|
289
|
+
extractDeepLTranslation,
|
|
290
|
+
extractLibreTranslateTranslation,
|
|
291
|
+
buildProviderRequest,
|
|
292
|
+
normalizeProvider,
|
|
170
293
|
detectRateLimitError,
|
|
171
294
|
translateText,
|
|
172
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
|
+
};
|