diff --git a/CHANGELOG.md b/CHANGELOG.md index 81299a560..304992299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ entity MyEntity { ### Fixed - Querying from content column while using object storage now returns expected result. +- Replaced `axios` with the built-in `fetch` API, removing an external dependency ## Version 3.12.1 diff --git a/lib/helper.js b/lib/helper.js index 9debe9fe7..b6f28af31 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -1,4 +1,3 @@ -const axios = require("axios") const https = require("https") const crypto = require("crypto") const stream = require("stream/promises") @@ -6,6 +5,53 @@ const cds = require("@sap/cds") const LOG = cds.log("attachments") const { extname } = require("path") +async function _fetch(url, options = {}) { + const res = await fetch(url, options) + if (!res.ok) { + const data = await res.json().catch(() => null) + const err = new Error(`HTTP ${res.status} ${res.statusText}`) + err.response = { status: res.status, data } + throw err + } + const data = await res.json().catch(() => null) + return data +} + +async function _fetchWithAgent(url, { agent, ...options }) { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const reqOptions = { + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers || {}, + agent, + } + const req = https.request(reqOptions, (res) => { + const chunks = [] + res.on("data", (chunk) => chunks.push(chunk)) + res.on("end", () => { + let data + try { + data = JSON.parse(Buffer.concat(chunks).toString()) + } catch { + data = null + } + if (res.statusCode >= 400) { + const err = new Error(`HTTP ${res.statusCode}`) + err.response = { status: res.statusCode, data } + return reject(err) + } + resolve(data) + }) + }) + req.on("error", reject) + if (options.body) req.write(options.body) + req.end() + }) +} + /** * Validates the presence of required Service Manager credentials * @param {*} serviceManagerCreds - Service Manager credentials object @@ -135,10 +181,10 @@ async function fetchObjectStoreBinding(tenantID, token) { endpoint: `${sm_url}/v1/service_bindings`, labelQuery: `service eq 'OBJECT_STORE' and tenant_id eq '${tenantID}'`, }) - const response = await axios.get(`${sm_url}/v1/service_bindings`, { - params: { - labelQuery: `service eq 'OBJECT_STORE' and tenant_id eq '${tenantID}'`, - }, + const params = new URLSearchParams({ + labelQuery: `service eq 'OBJECT_STORE' and tenant_id eq '${tenantID}'`, + }) + const response = await _fetch(`${sm_url}/v1/service_bindings?${params}`, { headers: { Accept: "application/json", Authorization: `Bearer ${token}`, @@ -146,7 +192,7 @@ async function fetchObjectStoreBinding(tenantID, token) { }, }) - return response.data?.items || [] + return response?.items || [] } /** @@ -274,23 +320,24 @@ async function fetchTokenWithClientSecret(url, clientid, clientsecret) { "Content-Type": "application/x-www-form-urlencoded", } - const response = await axios.post(`${url}/oauth/token`, null, { + const params = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientid, + client_secret: clientsecret, + }) + const response = await _fetch(`${url}/oauth/token?${params}`, { + method: "POST", headers, - params: { - grant_type: "client_credentials", - client_id: clientid, - client_secret: clientsecret, - }, }) const duration = Date.now() - startTime LOG.debug("OAuth token fetched successfully", { clientid, duration, - tokenType: response.data?.token_type, + tokenType: response?.token_type, }) - return response.data.access_token + return response.access_token } catch (error) { const duration = Date.now() - startTime const suggestion = @@ -339,40 +386,38 @@ async function fetchTokenWithMTLS(certURL, clientid, certificate, key) { client_id: clientid, }).toString() - const options = { + const agent = new https.Agent({ + cert: certificate, + key: key, + }) + + const response = await _fetchWithAgent(`${certURL}/oauth/token`, { + method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, - url: `${certURL}/oauth/token`, - method: "POST", - data: requestBody, - httpsAgent: new https.Agent({ - cert: certificate, - key: key, - }), - } - - const response = await axios(options) - const duration = Date.now() - startTime - - if (!response.data?.access_token) { + body: requestBody, + agent, + }) + if (!response?.access_token) { LOG.error( "MTLS token response missing access_token", null, "Check MTLS certificate/key validity and Service Manager configuration", - { clientid, duration, responseData: response.data }, + { clientid, duration: Date.now() - startTime, responseData: response }, ) throw new Error("Access token not found in MTLS token response") } + const duration = Date.now() - startTime LOG.debug("MTLS token fetched successfully", { clientid, duration, - tokenType: response.data.token_type, + tokenType: response.token_type, }) - return response.data.access_token + return response.access_token } catch (error) { const duration = Date.now() - startTime diff --git a/lib/mtx/server.js b/lib/mtx/server.js index 6eca2bfc0..38e9ea971 100644 --- a/lib/mtx/server.js +++ b/lib/mtx/server.js @@ -1,6 +1,5 @@ const cds = require("@sap/cds") const LOG = cds.log("attachments") -const axios = require("axios") const https = require("https") const { validateServiceManagerCredentials, @@ -28,6 +27,53 @@ const STATE = { let POLL_WAIT_TIME = 5000 const ASYNC_TIMEOUT = 5 * 60 * 1000 +async function _fetch(url, options = {}) { + const res = await fetch(url, options) + if (!res.ok) { + const data = await res.json().catch(() => null) + const err = new Error(`HTTP ${res.status} ${res.statusText}`) + err.response = { status: res.status, data } + throw err + } + const data = await res.json().catch(() => null) + return { data, headers: res.headers } +} + +async function _fetchWithAgent(url, { agent, ...options }) { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const reqOptions = { + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers || {}, + agent, + } + const req = https.request(reqOptions, (res) => { + const chunks = [] + res.on("data", (chunk) => chunks.push(chunk)) + res.on("end", () => { + let data + try { + data = JSON.parse(Buffer.concat(chunks).toString()) + } catch { + data = null + } + if (res.statusCode >= 400) { + const err = new Error(`HTTP ${res.statusCode}`) + err.response = { status: res.statusCode, data } + return reject(err) + } + resolve({ data, headers: res.headers }) + }) + }) + req.on("error", reject) + if (options.body) req.write(options.body) + req.end() + }) +} + /** * Waits for the specified number of milliseconds * @param {number} milliseconds - Time to wait in milliseconds @@ -45,7 +91,7 @@ async function wait(milliseconds) { /** * Registers attachment handlers for the given service and entity * @param {string} sm_url - Service Manager URL - * @param {import('axios').Method} method - HTTP method + * @param {string} method - HTTP method * @param {string} path - API path * @param {string} token - OAuth token * @param {*} params - Query parameters @@ -59,17 +105,18 @@ const _serviceManagerRequest = async ( params = {}, ) => { try { - const response = await axios({ - method, - url: `${sm_url}/${path}`, + const queryString = Object.keys(params).length + ? `?${new URLSearchParams(params)}` + : "" + const { data } = await _fetch(`${sm_url}/${path}${queryString}`, { + method: method.toUpperCase(), headers: { Accept: "application/json", Authorization: `Bearer ${token}`, }, - params, }) - return response?.data?.items?.[0] // Error handling : return undefined instead of crashing when .items is undefined + return data?.items?.[0] } catch (error) { LOG.error( `Service Manager API request failed - ${method.toUpperCase()} ${path}`, @@ -96,26 +143,27 @@ const _fetchToken = async (url, clientid, clientsecret, certificate, key) => { url, clientid, }) - const response = await axios.post(tokenUrl, null, { + const params = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientid, + client_secret: clientsecret, + }) + const { data: tokenData } = await _fetch(`${tokenUrl}?${params}`, { + method: "POST", headers, - params: { - grant_type: "client_credentials", - client_id: clientid, - client_secret: clientsecret, - }, }) - if (!response.data?.access_token) { + if (!tokenData?.access_token) { LOG.error( "OAuth token response missing access_token", null, "Check clientid/clientsecret validity and Service Manager configuration", - { clientid, responseData: response.data }, + { clientid, responseData: tokenData }, ) throw new Error("Access token not found in OAuth token response") } - return response.data.access_token + return tokenData.access_token } LOG.debug( @@ -131,22 +179,24 @@ const _fetchToken = async (url, clientid, clientsecret, certificate, key) => { ) const agent = new https.Agent({ cert: certificate, key: key }) - const response = await axios.post(tokenUrl, body, { + const { data: mtlsData } = await _fetchWithAgent(tokenUrl, { + method: "POST", headers, - httpsAgent: agent, + body, + agent, }) - if (!response.data?.access_token) { + if (!mtlsData?.access_token) { LOG.error( "MTLS token response missing access_token", null, "Check MTLS certificate/key validity and Service Manager configuration", - { clientid, responseData: response.data }, + { clientid, responseData: mtlsData }, ) throw new Error("Access token not found in MTLS token response") } - return response.data.access_token + return mtlsData.access_token } // If neither flow is possible @@ -238,23 +288,21 @@ const _getPlanID = async (sm_url, token, offeringID) => { */ const _createObjectStoreInstance = async (sm_url, tenant, planID, token) => { try { - const response = await axios.post( - `${sm_url}/v1/service_instances`, - { + const { headers } = await _fetch(`${sm_url}/v1/service_instances`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: `object-store-${tenant}-${cds.utils.uuid()}`, service_plan_id: planID, parameters: {}, labels: { tenant_id: [tenant], service: ["OBJECT_STORE"] }, - }, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - ) - const instancePath = response.headers.location.substring(1) + }), + }) + const instancePath = headers.get("location").substring(1) const instanceId = await _pollUntilDone(sm_url, instancePath, token) return instanceId.data.resource_id } catch (error) { @@ -283,7 +331,7 @@ const _pollUntilDone = async (sm_url, instancePath, token) => { await wait(POLL_WAIT_TIME * iteration) iteration++ - const instanceStatus = await axios.get(`${sm_url}/${instancePath}`, { + const instanceStatus = await _fetch(`${sm_url}/${instancePath}`, { headers: { Accept: "application/json", Authorization: `Bearer ${token}`, @@ -331,23 +379,21 @@ const _pollUntilDone = async (sm_url, instancePath, token) => { const _bindObjectStoreInstance = async (sm_url, tenant, instanceID, token) => { if (instanceID) { try { - const response = await axios.post( - `${sm_url}/${PATH.SERVICE_BINDING}`, - { + const { data } = await _fetch(`${sm_url}/${PATH.SERVICE_BINDING}`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: `object-store-${tenant}-${cds.utils.uuid()}`, service_instance_id: instanceID, parameters: {}, labels: { tenant_id: [tenant], service: ["OBJECT_STORE"] }, - }, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - ) - return response.data.id + }), + }) + return data.id } catch (error) { LOG.error( `Error binding object store instance for tenant - ${tenant}`, @@ -403,7 +449,8 @@ const _getBindingIdForDeletion = async (sm_url, tenant, token) => { const _deleteBinding = async (sm_url, bindingID, token) => { if (bindingID) { try { - await axios.delete(`${sm_url}/${PATH.SERVICE_BINDING}/${bindingID}`, { + await _fetch(`${sm_url}/${PATH.SERVICE_BINDING}/${bindingID}`, { + method: "DELETE", headers: { Accept: "application/json", Authorization: `Bearer ${token}`, @@ -458,16 +505,17 @@ const _getInstanceIdForDeletion = async (sm_url, tenant, token) => { const _deleteObjectStoreInstance = async (sm_url, instanceID, token) => { if (instanceID) { try { - const response = await axios.delete( + const { headers } = await _fetch( `${sm_url}/${PATH.SERVICE_INSTANCE}/${instanceID}`, { + method: "DELETE", headers: { Accept: "application/json", Authorization: `Bearer ${token}`, }, }, ) - const instancePath = response.headers.get("location").substring(1) + const instancePath = headers.get("location").substring(1) await _pollUntilDone(sm_url, instancePath, token) // remove LOG.debug("Object store instance deleted", { instanceID }) } catch (error) { diff --git a/package.json b/package.json index 7bbd54b35..55ba719a0 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "@aws-sdk/client-s3": "^3.993.0", "@aws-sdk/lib-storage": "^3.993.0", "@azure/storage-blob": "^12.31.0", - "@google-cloud/storage": "^7.19.0", - "axios": "^1.13.5" + "@google-cloud/storage": "^7.19.0" }, "devDependencies": { "@cap-js/cds-test": ">=0", diff --git a/tests/integration/attachments-non-draft.test.js b/tests/integration/attachments-non-draft.test.js index 11719cdf8..25dcba596 100644 --- a/tests/integration/attachments-non-draft.test.js +++ b/tests/integration/attachments-non-draft.test.js @@ -4,6 +4,7 @@ const { waitForScanStatus, newIncident, waitForDeletion, + delay, } = require("../utils/testUtils") const { join, resolve } = cds.utils.path const { createReadStream, readFileSync, statSync } = cds.utils.fs @@ -31,6 +32,9 @@ describe("Tests for uploading/deleting and fetching attachments through API call let log = test.log() const { createAttachmentMetadata, uploadAttachmentContent } = createHelpers() + // Allow background operations (malware scan status updates) to complete before teardown + afterAll(() => delay(2000)) + it("Create new entity and ensuring nothing attachment related crashes", async () => { const resCreate = await POST("/odata/v4/admin/Incidents", { title: "New Incident", diff --git a/tests/unit/unitTests.test.js b/tests/unit/unitTests.test.js index 4b86a2dc6..c5ee70021 100644 --- a/tests/unit/unitTests.test.js +++ b/tests/unit/unitTests.test.js @@ -12,15 +12,8 @@ jest.mock("@sap/cds", () => ({ env: { requires: {} }, })) -global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ malwareDetected: false }), - }), -) +global.fetch = jest.fn() -jest.mock("axios") - -// Mock individual functions used in malwareScanner since it imports logger jest.doMock("../../srv/malware-scanner/malwareScanner", () => { const original = jest.requireActual( "../../srv/malware-scanner/malwareScanner", @@ -38,7 +31,6 @@ const { sizeInBytes, MAX_FILE_SIZE, } = require("../../lib/helper") -const axios = require("axios") const cds = require("@sap/cds") beforeEach(() => { @@ -51,8 +43,9 @@ beforeEach(() => { } global.fetch = jest.fn(() => Promise.resolve({ - json: () => Promise.resolve({ malwareDetected: false }), + ok: true, status: 200, + json: () => Promise.resolve({ malwareDetected: false }), }), ) }) @@ -68,8 +61,18 @@ describe("getObjectStoreCredentials", () => { }, } - axios.get.mockResolvedValue({ data: { items: [{ id: "test-cred" }] } }) - axios.post.mockResolvedValue({ data: { access_token: "test-token" } }) + global.fetch = jest + .fn() + // First call: fetchTokenWithClientSecret (POST to /oauth/token) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: "test-token" }), + }) + // Second call: fetchObjectStoreBinding (GET service_bindings) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [{ id: "test-cred" }] }), + }) const creds = await getObjectStoreCredentials("tenant") expect(creds.id).toBe("test-cred") @@ -97,8 +100,11 @@ describe("getObjectStoreCredentials", () => { }) describe("fetchToken", () => { - it("should return a token when axios resolves", async () => { - axios.post.mockResolvedValue({ data: { access_token: "test-token" } }) + it("should return a token when fetch resolves", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: "test-token" }), + }) const token = await fetchToken("url", "clientId", "clientSecret") expect(token).toBe("test-token") }) @@ -116,7 +122,7 @@ describe("fetchToken", () => { }) it("should handle error and throw", async () => { - axios.post.mockRejectedValue(new Error("fail")) + global.fetch = jest.fn().mockRejectedValueOnce(new Error("fail")) await expect(fetchToken("url", "clientId", "clientSecret")).rejects.toThrow( "fail", ) diff --git a/tests/unit/validateAttachmentMimeType.test.js b/tests/unit/validateAttachmentMimeType.test.js index 379ecb65b..268601447 100644 --- a/tests/unit/validateAttachmentMimeType.test.js +++ b/tests/unit/validateAttachmentMimeType.test.js @@ -5,11 +5,14 @@ const { join } = cds.utils.path const app = join(__dirname, "../incidents-app") const { axios, POST, PUT, GET } = cds.test(app) const { validateAttachmentMimeType } = require("../../lib/generic-handlers") -const { newIncident } = require("../utils/testUtils") +const { newIncident, delay } = require("../utils/testUtils") describe("validateAttachmentMimeType - Content-Type header bypass security test", () => { axios.defaults.auth = { username: "alice" } + // Allow background operations (malware scan status updates) to complete before teardown + afterAll(() => delay(2000)) + /** * Security Test: Content-Type Header Bypass Attack *