889 lines
27 KiB
JavaScript
889 lines
27 KiB
JavaScript
import crypto from 'node:crypto'
|
|
import dns from 'node:dns/promises'
|
|
import net from 'node:net'
|
|
import { URL } from 'node:url'
|
|
import express from 'express'
|
|
|
|
const app = express()
|
|
|
|
const PORT = Math.max(1, Number(process.env.PORT || 8787) || 8787)
|
|
const PROXY_TOKEN = String(process.env.PROXY_TOKEN || '').trim()
|
|
const CONTOUR_API_BASE_URL = String(
|
|
process.env.CONTOUR_API_BASE_URL || 'https://api-zakupki.kontur.ru/external/v1',
|
|
).trim().replace(/\/+$/, '')
|
|
const CONTOUR_API_KEY = String(process.env.CONTOUR_API_KEY || '').trim()
|
|
const REQUEST_TIMEOUT_MS = Math.max(1000, Number(process.env.REQUEST_TIMEOUT_MS || 30000) || 30000)
|
|
const MAX_REDIRECTS = Math.max(0, Number(process.env.MAX_REDIRECTS || 5) || 5)
|
|
const MAX_JSON_RESPONSE_BYTES = Math.max(
|
|
1024 * 64,
|
|
Number(process.env.MAX_JSON_RESPONSE_BYTES || 10 * 1024 * 1024) || (10 * 1024 * 1024),
|
|
)
|
|
const MAX_FETCH_BYTES = Math.max(
|
|
1024 * 256,
|
|
Number(process.env.MAX_FETCH_BYTES || 50 * 1024 * 1024) || (50 * 1024 * 1024),
|
|
)
|
|
const MAX_CESIUM_FETCH_BYTES = Math.max(
|
|
1024 * 256,
|
|
Number(process.env.MAX_CESIUM_FETCH_BYTES || 128 * 1024 * 1024) || (128 * 1024 * 1024),
|
|
)
|
|
const BLOCK_PRIVATE_NETWORKS = String(process.env.BLOCK_PRIVATE_NETWORKS || '1').trim() !== '0'
|
|
const LOG_LEVEL = String(process.env.LOG_LEVEL || 'info').trim().toLowerCase() || 'info'
|
|
const DEBUG_FETCH = ['1', 'true', 'yes', 'on'].includes(String(process.env.DEBUG_FETCH || '').trim().toLowerCase())
|
|
// Map Gateway has a dedicated outbound route. It is deliberately separate
|
|
// from /proxy/fetch: only the provider hosts required by the canonical Map
|
|
// Page can use it, and Cesium credentials never become generic proxy headers.
|
|
const CESIUM_EGRESS_HOSTS = new Set([
|
|
'api.cesium.com',
|
|
'assets.ion.cesium.com',
|
|
'dev.virtualearth.net',
|
|
'ecn.t0.tiles.virtualearth.net',
|
|
'ecn.t1.tiles.virtualearth.net',
|
|
'ecn.t2.tiles.virtualearth.net',
|
|
'ecn.t3.tiles.virtualearth.net',
|
|
])
|
|
const CREDENTIAL_QUERY_KEYS = new Set([
|
|
'access_token',
|
|
'accesstoken',
|
|
'iontoken',
|
|
'token',
|
|
'key',
|
|
'apikey',
|
|
'api_key',
|
|
'signature',
|
|
'sig',
|
|
])
|
|
const LOG_LEVEL_WEIGHT = {
|
|
debug: 10,
|
|
info: 20,
|
|
warn: 30,
|
|
error: 40,
|
|
}
|
|
|
|
app.set('trust proxy', true)
|
|
app.disable('x-powered-by')
|
|
app.use(express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }))
|
|
|
|
function shouldLog(level = 'info') {
|
|
const wanted = LOG_LEVEL_WEIGHT[String(LOG_LEVEL || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info
|
|
const current = LOG_LEVEL_WEIGHT[String(level || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info
|
|
return current >= wanted
|
|
}
|
|
|
|
function log(level, message, fields = {}) {
|
|
if (!shouldLog(level)) return
|
|
const line = {
|
|
ts: new Date().toISOString(),
|
|
level,
|
|
message,
|
|
...fields,
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.log(JSON.stringify(line))
|
|
}
|
|
|
|
function getRequestId(req) {
|
|
const fromHeader = String(req.headers['x-request-id'] || '').trim()
|
|
return fromHeader || crypto.randomUUID()
|
|
}
|
|
|
|
function sanitizeErrorMessage(error, fallback = 'proxy_request_failed') {
|
|
const text = String(error?.message || '').trim()
|
|
if (!text) return fallback
|
|
return text.slice(0, 500)
|
|
}
|
|
|
|
function parseBoolean(raw, fallback = false) {
|
|
const text = String(raw ?? '').trim().toLowerCase()
|
|
if (!text) return fallback
|
|
if (['1', 'true', 'yes', 'on'].includes(text)) return true
|
|
if (['0', 'false', 'no', 'off'].includes(text)) return false
|
|
return fallback
|
|
}
|
|
|
|
function isIpv4Private(address) {
|
|
const parts = String(address || '').split('.').map((s) => Number(s))
|
|
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
const [a, b] = parts
|
|
if (a === 10) return true
|
|
if (a === 127) return true
|
|
if (a === 0) return true
|
|
if (a === 169 && b === 254) return true
|
|
if (a === 172 && b >= 16 && b <= 31) return true
|
|
if (a === 192 && b === 168) return true
|
|
if (a === 100 && b >= 64 && b <= 127) return true
|
|
if (a === 198 && (b === 18 || b === 19)) return true
|
|
if (a >= 224) return true
|
|
return false
|
|
}
|
|
|
|
function ipv6ToBigInt(address) {
|
|
const input = String(address || '').toLowerCase()
|
|
if (!input) return null
|
|
const percent = input.indexOf('%')
|
|
const src = percent >= 0 ? input.slice(0, percent) : input
|
|
if (!src.includes(':')) return null
|
|
|
|
const hasDouble = src.includes('::')
|
|
if (hasDouble && src.indexOf('::') !== src.lastIndexOf('::')) return null
|
|
|
|
const [headRaw, tailRaw] = hasDouble ? src.split('::') : [src, '']
|
|
const head = headRaw ? headRaw.split(':').filter(Boolean) : []
|
|
const tail = tailRaw ? tailRaw.split(':').filter(Boolean) : []
|
|
const missing = 8 - (head.length + tail.length)
|
|
if (missing < 0) return null
|
|
|
|
const chunks = hasDouble
|
|
? [...head, ...new Array(missing).fill('0'), ...tail]
|
|
: src.split(':')
|
|
if (chunks.length !== 8) return null
|
|
|
|
let out = 0n
|
|
for (const chunk of chunks) {
|
|
if (!/^[0-9a-f]{1,4}$/i.test(chunk)) return null
|
|
const value = BigInt(parseInt(chunk, 16))
|
|
out = (out << 16n) + value
|
|
}
|
|
return out
|
|
}
|
|
|
|
function isIpv6Private(address) {
|
|
const normalized = String(address || '').toLowerCase()
|
|
if (!normalized) return false
|
|
if (normalized === '::1' || normalized === '::') return true
|
|
const value = ipv6ToBigInt(normalized)
|
|
if (value === null) return false
|
|
|
|
// fc00::/7
|
|
if ((value & 0xfe00_0000_0000_0000_0000_0000_0000_0000n) === 0xfc00_0000_0000_0000_0000_0000_0000_0000n) return true
|
|
// fe80::/10
|
|
if ((value & 0xffc0_0000_0000_0000_0000_0000_0000_0000n) === 0xfe80_0000_0000_0000_0000_0000_0000_0000n) return true
|
|
// ff00::/8 multicast
|
|
if ((value & 0xff00_0000_0000_0000_0000_0000_0000_0000n) === 0xff00_0000_0000_0000_0000_0000_0000_0000n) return true
|
|
return false
|
|
}
|
|
|
|
function isPrivateAddress(address) {
|
|
const kind = net.isIP(address)
|
|
if (kind === 4) return isIpv4Private(address)
|
|
if (kind === 6) return isIpv6Private(address)
|
|
return false
|
|
}
|
|
|
|
async function assertUrlIsSafe(targetUrl) {
|
|
let parsed = null
|
|
try {
|
|
parsed = new URL(String(targetUrl || ''))
|
|
} catch {
|
|
const err = new Error('invalid_target_url')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
|
|
const protocol = String(parsed.protocol || '').toLowerCase()
|
|
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
const err = new Error('unsupported_target_protocol')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
|
|
const hostname = String(parsed.hostname || '').trim()
|
|
if (!hostname) {
|
|
const err = new Error('target_hostname_required')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
|
|
if (!BLOCK_PRIVATE_NETWORKS) return parsed
|
|
|
|
const lowerHost = hostname.toLowerCase()
|
|
if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
|
|
const err = new Error('target_host_not_allowed')
|
|
err.statusCode = 403
|
|
throw err
|
|
}
|
|
|
|
const literalIpKind = net.isIP(hostname)
|
|
if (literalIpKind && isPrivateAddress(hostname)) {
|
|
const err = new Error('target_private_ip_not_allowed')
|
|
err.statusCode = 403
|
|
throw err
|
|
}
|
|
|
|
let resolved = []
|
|
try {
|
|
resolved = await dns.lookup(hostname, { all: true, verbatim: true })
|
|
} catch {
|
|
// If DNS cannot resolve now, let fetch handle it later.
|
|
return parsed
|
|
}
|
|
|
|
for (const entry of resolved) {
|
|
const address = String(entry?.address || '').trim()
|
|
if (!address) continue
|
|
if (isPrivateAddress(address)) {
|
|
const err = new Error('target_resolves_to_private_network')
|
|
err.statusCode = 403
|
|
throw err
|
|
}
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
function assertCesiumEgressTarget(target) {
|
|
const hostname = String(target?.hostname || '').trim().toLowerCase()
|
|
if (CESIUM_EGRESS_HOSTS.has(hostname)) return target
|
|
const err = new Error('cesium_egress_host_not_allowed')
|
|
err.statusCode = 403
|
|
throw err
|
|
}
|
|
|
|
function redactCredentialUrl(rawUrl) {
|
|
try {
|
|
const parsed = new URL(String(rawUrl || ''))
|
|
for (const key of [...parsed.searchParams.keys()]) {
|
|
if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) parsed.searchParams.set(key, '<redacted>')
|
|
}
|
|
return parsed.toString()
|
|
} catch {
|
|
return '[invalid-target-url]'
|
|
}
|
|
}
|
|
|
|
function mapEgressReferer(rawReferer) {
|
|
const value = String(rawReferer || '').trim()
|
|
if (!value) return ''
|
|
let parsed
|
|
try {
|
|
parsed = new URL(value)
|
|
} catch {
|
|
const err = new Error('invalid_map_egress_referer')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
const err = new Error('invalid_map_egress_referer')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
parsed.search = ''
|
|
parsed.hash = ''
|
|
return parsed.toString()
|
|
}
|
|
|
|
function mapEgressHeaders(req, target, requestId) {
|
|
const authorization = String(req.headers['x-nodedc-cesium-authorization'] || '').trim()
|
|
if (authorization && !/^Bearer\s+[A-Za-z0-9._~+/=-]{16,4096}$/i.test(authorization)) {
|
|
const err = new Error('invalid_cesium_egress_authorization')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
const range = String(req.headers.range || '').trim()
|
|
if (range && !/^bytes=\d*-\d*(?:,\d*-\d*)*$/i.test(range)) {
|
|
const err = new Error('invalid_map_egress_range')
|
|
err.statusCode = 400
|
|
throw err
|
|
}
|
|
const accept = String(req.headers.accept || 'application/json, application/octet-stream, image/*, */*;q=0.5').trim().slice(0, 512)
|
|
const referer = mapEgressReferer(req.headers['x-nodedc-map-referer'])
|
|
const headers = {
|
|
accept: accept || '*/*',
|
|
'user-agent': 'nodedc-map-egress/1.0',
|
|
'x-request-id': requestId,
|
|
...(referer ? { referer } : {}),
|
|
...(range ? { range } : {}),
|
|
}
|
|
// The master Ion token may only be sent to the Ion API. Redirects are
|
|
// validated per hop and never carry it to an asset/Bing host.
|
|
if (String(target?.hostname || '').toLowerCase() === 'api.cesium.com' && authorization) {
|
|
headers.authorization = authorization
|
|
}
|
|
return headers
|
|
}
|
|
|
|
function getProxyTokenFromReq(req) {
|
|
const fromHeader = String(req.headers['x-proxy-token'] || '').trim()
|
|
if (fromHeader) return fromHeader
|
|
const auth = String(req.headers.authorization || '').trim()
|
|
if (!auth) return ''
|
|
const m = /^Bearer\s+(.+)$/i.exec(auth)
|
|
return m ? String(m[1] || '').trim() : ''
|
|
}
|
|
|
|
function setAccessLogExtra(req, fields = {}) {
|
|
if (!req || typeof req !== 'object') return
|
|
const extra = req.accessLogExtra && typeof req.accessLogExtra === 'object'
|
|
? req.accessLogExtra
|
|
: {}
|
|
req.accessLogExtra = {
|
|
...extra,
|
|
...(fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : {}),
|
|
}
|
|
}
|
|
|
|
function authMiddleware(req, res, next) {
|
|
if (!PROXY_TOKEN) return next()
|
|
const token = getProxyTokenFromReq(req)
|
|
if (!token || token !== PROXY_TOKEN) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: 'unauthorized',
|
|
message: 'Invalid proxy token',
|
|
})
|
|
}
|
|
return next()
|
|
}
|
|
|
|
app.use((req, res, next) => {
|
|
const requestId = getRequestId(req)
|
|
req.requestId = requestId
|
|
res.setHeader('x-request-id', requestId)
|
|
next()
|
|
})
|
|
|
|
app.use((req, res, next) => {
|
|
const startedAt = process.hrtime.bigint()
|
|
req.accessLogExtra = {}
|
|
res.on('finish', () => {
|
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
|
const payload = {
|
|
requestId: req.requestId || '',
|
|
method: req.method,
|
|
path: req.path,
|
|
status: Number(res.statusCode || 0),
|
|
durationMs: Math.round(durationMs * 1000) / 1000,
|
|
...(req.accessLogExtra && typeof req.accessLogExtra === 'object' ? req.accessLogExtra : {}),
|
|
}
|
|
const level = Number(res.statusCode || 0) >= 500
|
|
? 'error'
|
|
: (Number(res.statusCode || 0) >= 400 ? 'warn' : 'info')
|
|
log(level, 'proxy access', payload)
|
|
})
|
|
next()
|
|
})
|
|
|
|
app.use(authMiddleware)
|
|
|
|
app.get('/healthz', (req, res) => {
|
|
res.json({
|
|
ok: true,
|
|
service: 'proxy-contur',
|
|
time: new Date().toISOString(),
|
|
mode: {
|
|
blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS,
|
|
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
maxRedirects: MAX_REDIRECTS,
|
|
maxFetchBytes: MAX_FETCH_BYTES,
|
|
hasContourApiKey: Boolean(CONTOUR_API_KEY),
|
|
},
|
|
})
|
|
})
|
|
|
|
function pickApiKey(req) {
|
|
if (CONTOUR_API_KEY) return CONTOUR_API_KEY
|
|
const headerApiKey = String(req.headers['x-kontur-apikey'] || '').trim()
|
|
if (headerApiKey) return headerApiKey
|
|
const queryApiKey = String(req.query?.apiKey || req.query?.api_key || '').trim()
|
|
if (queryApiKey) return queryApiKey
|
|
const bodyApiKey = String(req.body?.apiKey || req.body?.api_key || '').trim()
|
|
return bodyApiKey
|
|
}
|
|
|
|
async function fetchWithTimeout(url, {
|
|
method = 'GET',
|
|
headers = {},
|
|
body = undefined,
|
|
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
redirect = 'manual',
|
|
} = {}) {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs))
|
|
try {
|
|
return await fetch(url, {
|
|
method,
|
|
headers,
|
|
body,
|
|
redirect,
|
|
signal: controller.signal,
|
|
})
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function readResponseTextLimited(response, maxBytes = MAX_JSON_RESPONSE_BYTES) {
|
|
if (!response?.body) return ''
|
|
const reader = response.body.getReader()
|
|
const chunks = []
|
|
let total = 0
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
const buf = Buffer.from(value)
|
|
total += buf.length
|
|
if (total > maxBytes) {
|
|
const err = new Error('upstream_response_too_large')
|
|
err.statusCode = 413
|
|
throw err
|
|
}
|
|
chunks.push(buf)
|
|
}
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
return Buffer.concat(chunks).toString('utf8')
|
|
}
|
|
|
|
function setJsonPassthroughHeaders(res, upstream) {
|
|
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8'
|
|
res.setHeader('content-type', contentType)
|
|
}
|
|
|
|
app.post('/proxy/contour/search', async (req, res) => {
|
|
const requestId = req.requestId
|
|
const apiKey = pickApiKey(req)
|
|
if (!apiKey) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'contour_apikey_required',
|
|
message: 'X-Kontur-Apikey is required',
|
|
requestId,
|
|
})
|
|
}
|
|
|
|
const targetUrl = `${CONTOUR_API_BASE_URL}/search`
|
|
const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
|
|
try {
|
|
await assertUrlIsSafe(targetUrl)
|
|
const upstream = await fetchWithTimeout(targetUrl, {
|
|
method: 'POST',
|
|
timeoutMs,
|
|
headers: {
|
|
accept: 'application/json, text/plain, */*',
|
|
'content-type': 'application/json',
|
|
'x-kontur-apikey': apiKey,
|
|
'x-request-id': requestId,
|
|
},
|
|
body: JSON.stringify(req.body && typeof req.body === 'object' ? req.body : {}),
|
|
})
|
|
const bodyText = await readResponseTextLimited(upstream)
|
|
setAccessLogExtra(req, {
|
|
route: 'proxy_contour_search',
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
|
|
})
|
|
setJsonPassthroughHeaders(res, upstream)
|
|
return res.status(upstream.status).send(bodyText)
|
|
} catch (error) {
|
|
const statusCode = Number(error?.statusCode || 502) || 502
|
|
log('error', 'proxy contour search failed', {
|
|
requestId,
|
|
statusCode,
|
|
message: sanitizeErrorMessage(error),
|
|
})
|
|
return res.status(statusCode).json({
|
|
ok: false,
|
|
error: 'proxy_contour_search_failed',
|
|
message: sanitizeErrorMessage(error),
|
|
requestId,
|
|
})
|
|
}
|
|
})
|
|
|
|
app.get('/proxy/contour/purchases/:purchaseId', async (req, res) => {
|
|
const requestId = req.requestId
|
|
const purchaseId = String(req.params?.purchaseId || '').trim()
|
|
if (!purchaseId) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'purchase_id_required',
|
|
message: 'purchaseId is required',
|
|
requestId,
|
|
})
|
|
}
|
|
const apiKey = pickApiKey(req)
|
|
if (!apiKey) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'contour_apikey_required',
|
|
message: 'X-Kontur-Apikey is required',
|
|
requestId,
|
|
})
|
|
}
|
|
const targetUrl = `${CONTOUR_API_BASE_URL}/purchases/${encodeURIComponent(purchaseId)}`
|
|
const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
|
|
try {
|
|
await assertUrlIsSafe(targetUrl)
|
|
const upstream = await fetchWithTimeout(targetUrl, {
|
|
method: 'GET',
|
|
timeoutMs,
|
|
headers: {
|
|
accept: 'application/json, text/plain, */*',
|
|
'x-kontur-apikey': apiKey,
|
|
'x-request-id': requestId,
|
|
},
|
|
})
|
|
const bodyText = await readResponseTextLimited(upstream)
|
|
setAccessLogExtra(req, {
|
|
route: 'proxy_contour_purchase',
|
|
purchaseId,
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
|
|
})
|
|
setJsonPassthroughHeaders(res, upstream)
|
|
return res.status(upstream.status).send(bodyText)
|
|
} catch (error) {
|
|
const statusCode = Number(error?.statusCode || 502) || 502
|
|
log('error', 'proxy contour purchase failed', {
|
|
requestId,
|
|
statusCode,
|
|
purchaseId,
|
|
message: sanitizeErrorMessage(error),
|
|
})
|
|
return res.status(statusCode).json({
|
|
ok: false,
|
|
error: 'proxy_contour_purchase_failed',
|
|
message: sanitizeErrorMessage(error),
|
|
requestId,
|
|
})
|
|
}
|
|
})
|
|
|
|
app.get('/proxy/contour/limit-groups', async (req, res) => {
|
|
const requestId = req.requestId
|
|
const apiKey = pickApiKey(req)
|
|
if (!apiKey) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'contour_apikey_required',
|
|
message: 'X-Kontur-Apikey is required',
|
|
requestId,
|
|
})
|
|
}
|
|
const targetUrl = `${CONTOUR_API_BASE_URL}/limitGroups`
|
|
const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
|
|
try {
|
|
await assertUrlIsSafe(targetUrl)
|
|
const upstream = await fetchWithTimeout(targetUrl, {
|
|
method: 'GET',
|
|
timeoutMs,
|
|
headers: {
|
|
accept: 'application/json, text/plain, */*',
|
|
'x-kontur-apikey': apiKey,
|
|
'x-request-id': requestId,
|
|
},
|
|
})
|
|
const bodyText = await readResponseTextLimited(upstream)
|
|
setAccessLogExtra(req, {
|
|
route: 'proxy_contour_limit_groups',
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
|
|
})
|
|
setJsonPassthroughHeaders(res, upstream)
|
|
return res.status(upstream.status).send(bodyText)
|
|
} catch (error) {
|
|
const statusCode = Number(error?.statusCode || 502) || 502
|
|
log('error', 'proxy contour limit groups failed', {
|
|
requestId,
|
|
statusCode,
|
|
message: sanitizeErrorMessage(error),
|
|
})
|
|
return res.status(statusCode).json({
|
|
ok: false,
|
|
error: 'proxy_contour_limit_groups_failed',
|
|
message: sanitizeErrorMessage(error),
|
|
requestId,
|
|
})
|
|
}
|
|
})
|
|
|
|
function copyFetchHeaders(res, upstreamHeaders, { omitContentLength = false } = {}) {
|
|
const passHeaders = [
|
|
'content-type',
|
|
'content-length',
|
|
'content-disposition',
|
|
'cache-control',
|
|
'etag',
|
|
'last-modified',
|
|
'accept-ranges',
|
|
]
|
|
for (const name of passHeaders) {
|
|
if (omitContentLength && name === 'content-length') continue
|
|
const value = upstreamHeaders.get(name)
|
|
if (!value) continue
|
|
res.setHeader(name, value)
|
|
}
|
|
}
|
|
|
|
async function fetchWithSafeRedirects(initialUrl, {
|
|
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
maxRedirects = MAX_REDIRECTS,
|
|
headers = {},
|
|
headersForTarget = null,
|
|
validateTarget = null,
|
|
redactForLog = null,
|
|
requestId = '',
|
|
} = {}) {
|
|
let currentUrl = String(initialUrl || '')
|
|
let redirects = 0
|
|
|
|
while (true) {
|
|
const parsed = await assertUrlIsSafe(currentUrl)
|
|
if (typeof validateTarget === 'function') validateTarget(parsed)
|
|
const requestHeaders = typeof headersForTarget === 'function'
|
|
? headersForTarget(parsed)
|
|
: headers
|
|
const upstream = await fetchWithTimeout(parsed.toString(), {
|
|
method: 'GET',
|
|
timeoutMs,
|
|
redirect: 'manual',
|
|
headers: requestHeaders,
|
|
})
|
|
const status = Number(upstream.status || 0)
|
|
const location = String(upstream.headers.get('location') || '').trim()
|
|
const isRedirect = status >= 300 && status < 400 && location
|
|
if (!isRedirect) {
|
|
return { upstream, finalUrl: parsed.toString(), redirects }
|
|
}
|
|
|
|
if (redirects >= maxRedirects) {
|
|
const err = new Error('too_many_redirects')
|
|
err.statusCode = 508
|
|
throw err
|
|
}
|
|
let nextUrl = ''
|
|
try {
|
|
nextUrl = new URL(location, parsed.toString()).toString()
|
|
} catch {
|
|
const err = new Error('invalid_redirect_url')
|
|
err.statusCode = 502
|
|
throw err
|
|
}
|
|
if (DEBUG_FETCH) {
|
|
const safe = typeof redactForLog === 'function' ? redactForLog : (value) => value
|
|
log('debug', 'proxy fetch redirect', {
|
|
requestId: String(requestId || ''),
|
|
from: String(safe(parsed.toString())).slice(0, 512),
|
|
to: String(safe(nextUrl)).slice(0, 512),
|
|
status,
|
|
redirects: redirects + 1,
|
|
})
|
|
}
|
|
currentUrl = nextUrl
|
|
redirects += 1
|
|
}
|
|
}
|
|
|
|
function createFetchRoute({
|
|
route,
|
|
errorCode,
|
|
buildHeaders,
|
|
validateTarget = null,
|
|
redactTarget = false,
|
|
allowRequestTuning = true,
|
|
omitContentLength = false,
|
|
fixedMaxBytes = MAX_FETCH_BYTES,
|
|
}) {
|
|
return async (req, res) => {
|
|
const requestId = req.requestId
|
|
const rawUrl = String(req.query?.url || '').trim()
|
|
const safeTarget = redactTarget ? redactCredentialUrl(rawUrl) : rawUrl.slice(0, 400)
|
|
if (!rawUrl) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'url_required',
|
|
message: 'query parameter "url" is required',
|
|
requestId,
|
|
})
|
|
}
|
|
|
|
const timeoutMs = allowRequestTuning
|
|
? Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
|
|
: REQUEST_TIMEOUT_MS
|
|
const maxBytes = allowRequestTuning
|
|
? Math.max(1024 * 64, Number(req.query?.maxBytes || MAX_FETCH_BYTES) || MAX_FETCH_BYTES)
|
|
: fixedMaxBytes
|
|
|
|
try {
|
|
const { upstream, finalUrl, redirects } = await fetchWithSafeRedirects(rawUrl, {
|
|
timeoutMs,
|
|
maxRedirects: MAX_REDIRECTS,
|
|
headersForTarget: (target) => buildHeaders(req, target, requestId),
|
|
validateTarget,
|
|
redactForLog: redactTarget ? redactCredentialUrl : null,
|
|
requestId,
|
|
})
|
|
const safeFinalUrl = redactTarget ? redactCredentialUrl(finalUrl) : finalUrl
|
|
const contentType = String(upstream.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
|
|
const contentLength = Number(upstream.headers.get('content-length') || 0)
|
|
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxBytes) {
|
|
setAccessLogExtra(req, {
|
|
route,
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
finalUrl: String(safeFinalUrl || '').slice(0, 512),
|
|
redirects,
|
|
contentType,
|
|
bytes: contentLength,
|
|
target: safeTarget,
|
|
error: 'fetch_too_large',
|
|
})
|
|
return res.status(413).json({
|
|
ok: false,
|
|
error: 'fetch_too_large',
|
|
message: 'Content-Length exceeds maxBytes',
|
|
requestId,
|
|
finalUrl: safeFinalUrl,
|
|
contentLength,
|
|
maxBytes,
|
|
})
|
|
}
|
|
|
|
copyFetchHeaders(res, upstream.headers, { omitContentLength })
|
|
res.setHeader('x-proxy-final-url', safeFinalUrl)
|
|
res.setHeader('x-proxy-redirects', String(redirects))
|
|
res.status(upstream.status)
|
|
|
|
if (!upstream.body) {
|
|
setAccessLogExtra(req, {
|
|
route,
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
finalUrl: String(safeFinalUrl || '').slice(0, 512),
|
|
redirects,
|
|
contentType,
|
|
bytes: 0,
|
|
target: safeTarget,
|
|
})
|
|
return res.end()
|
|
}
|
|
|
|
const reader = upstream.body.getReader()
|
|
let total = 0
|
|
const onClose = () => {
|
|
reader.cancel().catch(() => {})
|
|
}
|
|
req.on('close', onClose)
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
const chunk = Buffer.from(value)
|
|
total += chunk.length
|
|
if (total > maxBytes) {
|
|
setAccessLogExtra(req, {
|
|
route,
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
finalUrl: String(safeFinalUrl || '').slice(0, 512),
|
|
redirects,
|
|
contentType,
|
|
bytes: total,
|
|
target: safeTarget,
|
|
error: 'fetch_too_large_stream',
|
|
})
|
|
return res.status(413).end()
|
|
}
|
|
if (!res.write(chunk)) {
|
|
await new Promise((resolve) => res.once('drain', resolve))
|
|
}
|
|
}
|
|
setAccessLogExtra(req, {
|
|
route,
|
|
upstreamStatus: Number(upstream.status || 0),
|
|
finalUrl: String(safeFinalUrl || '').slice(0, 512),
|
|
redirects,
|
|
contentType,
|
|
bytes: total,
|
|
target: safeTarget,
|
|
})
|
|
return res.end()
|
|
} finally {
|
|
req.off('close', onClose)
|
|
reader.releaseLock()
|
|
}
|
|
} catch (error) {
|
|
const statusCode = Number(error?.statusCode || 502) || 502
|
|
setAccessLogExtra(req, {
|
|
route,
|
|
target: safeTarget,
|
|
error: sanitizeErrorMessage(error),
|
|
})
|
|
log('error', 'proxy fetch failed', {
|
|
requestId,
|
|
statusCode,
|
|
message: sanitizeErrorMessage(error),
|
|
target: safeTarget,
|
|
})
|
|
if (res.headersSent) return res.end()
|
|
return res.status(statusCode).json({
|
|
ok: false,
|
|
error: errorCode,
|
|
message: sanitizeErrorMessage(error),
|
|
requestId,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
app.get('/proxy/fetch', createFetchRoute({
|
|
route: 'proxy_fetch',
|
|
errorCode: 'proxy_fetch_failed',
|
|
buildHeaders: (req, _target, requestId) => {
|
|
const includeApiKey = parseBoolean(req.query?.includeApiKey, true)
|
|
const apiKey = includeApiKey ? pickApiKey(req) : ''
|
|
return {
|
|
accept: String(req.headers.accept || '*/*'),
|
|
'user-agent': String(req.headers['user-agent'] || 'proxy-contur/0.1'),
|
|
...(apiKey ? { 'x-kontur-apikey': apiKey } : {}),
|
|
'x-request-id': requestId,
|
|
}
|
|
},
|
|
}))
|
|
|
|
app.get('/proxy/cesium/fetch', createFetchRoute({
|
|
route: 'proxy_cesium_fetch',
|
|
errorCode: 'proxy_cesium_fetch_failed',
|
|
buildHeaders: mapEgressHeaders,
|
|
validateTarget: assertCesiumEgressTarget,
|
|
redactTarget: true,
|
|
allowRequestTuning: false,
|
|
omitContentLength: true,
|
|
fixedMaxBytes: MAX_CESIUM_FETCH_BYTES,
|
|
}))
|
|
|
|
app.use((req, res) => {
|
|
res.status(404).json({
|
|
ok: false,
|
|
error: 'not_found',
|
|
message: 'Route is not defined',
|
|
})
|
|
})
|
|
|
|
app.use((error, req, res, _next) => {
|
|
const requestId = req?.requestId || ''
|
|
const statusCode = Number(error?.statusCode || 500) || 500
|
|
log('error', 'unhandled error', {
|
|
requestId,
|
|
statusCode,
|
|
message: sanitizeErrorMessage(error, 'internal_error'),
|
|
})
|
|
res.status(statusCode).json({
|
|
ok: false,
|
|
error: 'internal_error',
|
|
message: sanitizeErrorMessage(error, 'internal_error'),
|
|
requestId,
|
|
})
|
|
})
|
|
|
|
app.listen(PORT, () => {
|
|
log('info', 'proxy-contur started', {
|
|
port: PORT,
|
|
contourApiBaseUrl: CONTOUR_API_BASE_URL,
|
|
blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS,
|
|
hasProxyToken: Boolean(PROXY_TOKEN),
|
|
hasContourApiKey: Boolean(CONTOUR_API_KEY),
|
|
logLevel: LOG_LEVEL,
|
|
debugFetch: DEBUG_FETCH,
|
|
})
|
|
})
|