diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml index e0c2628e2a1..808f86cbe32 100644 --- a/.github/actions/rust/action.yaml +++ b/.github/actions/rust/action.yaml @@ -123,7 +123,7 @@ runs: shell: bash if: runner.os == 'Linux' run: | - sudo apt update -qq + sudo apt update # snappy is required by rust rocksdb sudo apt install -qq --yes clang llvm libsnappy-dev sudo update-alternatives --set cc /usr/bin/clang diff --git a/packages/dashmate/src/ssl/zerossl/cancelCertificate.js b/packages/dashmate/src/ssl/zerossl/cancelCertificate.js index 2d8f9dfa9b7..fdaeb6b8cfe 100644 --- a/packages/dashmate/src/ssl/zerossl/cancelCertificate.js +++ b/packages/dashmate/src/ssl/zerossl/cancelCertificate.js @@ -9,7 +9,7 @@ import requestApi from './requestApi.js'; * @return {Promise} */ export default async function cancelCertificate(apiKey, id) { - const url = `https://api.zerossl.com/certificates/${id}/cancel?access_key=${apiKey}`; + const url = `https://api.zerossl.com/certificates/${id}/cancel`; const requestOptions = { method: 'POST', @@ -18,5 +18,5 @@ export default async function cancelCertificate(apiKey, id) { }, }; - return requestApi(url, requestOptions); + return requestApi(apiKey, url, requestOptions); } diff --git a/packages/dashmate/src/ssl/zerossl/createZeroSSLCertificate.js b/packages/dashmate/src/ssl/zerossl/createZeroSSLCertificate.js index bbb9854850f..a9efb50e6a3 100644 --- a/packages/dashmate/src/ssl/zerossl/createZeroSSLCertificate.js +++ b/packages/dashmate/src/ssl/zerossl/createZeroSSLCertificate.js @@ -22,7 +22,7 @@ export default async function createZeroSSLCertificate( certificate_csr: csr, }); - const url = `https://api.zerossl.com/certificates?access_key=${apiKey}`; + const url = 'https://api.zerossl.com/certificates'; const requestOptions = { method: 'POST', @@ -32,7 +32,7 @@ export default async function createZeroSSLCertificate( }, }; - const data = await requestApi(url, requestOptions); + const data = await requestApi(apiKey, url, requestOptions); return new Certificate(data); } diff --git a/packages/dashmate/src/ssl/zerossl/downloadCertificate.js b/packages/dashmate/src/ssl/zerossl/downloadCertificate.js index 0b0e681d642..6806306193c 100644 --- a/packages/dashmate/src/ssl/zerossl/downloadCertificate.js +++ b/packages/dashmate/src/ssl/zerossl/downloadCertificate.js @@ -9,14 +9,14 @@ import requestApi from './requestApi.js'; export default async function downloadCertificate(id, apiKey) { - const url = `https://api.zerossl.com/certificates/${id}/download/return?access_key=${apiKey}`; + const url = `https://api.zerossl.com/certificates/${id}/download/return`; const requestOptions = { method: 'GET', headers: { }, }; - const data = await requestApi(url, requestOptions); + const data = await requestApi(apiKey, url, requestOptions); return `${data['certificate.crt']}\n${data['ca_bundle.crt']}`; } diff --git a/packages/dashmate/src/ssl/zerossl/getCertificate.js b/packages/dashmate/src/ssl/zerossl/getCertificate.js index 5d52eb2ea47..669eafb20ce 100644 --- a/packages/dashmate/src/ssl/zerossl/getCertificate.js +++ b/packages/dashmate/src/ssl/zerossl/getCertificate.js @@ -10,14 +10,14 @@ import requestApi from './requestApi.js'; * @return {Promise} */ export default async function getCertificate(apiKey, id) { - const url = `https://api.zerossl.com/certificates/${id}?access_key=${apiKey}`; + const url = `https://api.zerossl.com/certificates/${id}`; const requestOptions = { method: 'GET', headers: { }, }; - const data = await requestApi(url, requestOptions); + const data = await requestApi(apiKey, url, requestOptions); return new Certificate(data); } diff --git a/packages/dashmate/src/ssl/zerossl/listCertificates.js b/packages/dashmate/src/ssl/zerossl/listCertificates.js index f7530cee4c9..259b4341cd1 100644 --- a/packages/dashmate/src/ssl/zerossl/listCertificates.js +++ b/packages/dashmate/src/ssl/zerossl/listCertificates.js @@ -19,7 +19,7 @@ export default async function listCertificates( page = 1, search = undefined, ) { - let url = `https://api.zerossl.com/certificates?access_key=${apiKey}&limit=1000&page=${page}`; + let url = `https://api.zerossl.com/certificates?limit=1000&page=${page}`; if (statuses.length > 0) { url += `&statuses=${statuses.join(',')}`; @@ -34,7 +34,7 @@ export default async function listCertificates( headers: {}, }; - const data = await requestApi(url, requestOptions); + const data = await requestApi(apiKey, url, requestOptions); return data.results.map((certificateData) => new Certificate(certificateData)); } diff --git a/packages/dashmate/src/ssl/zerossl/requestApi.js b/packages/dashmate/src/ssl/zerossl/requestApi.js index 3a0bebe699d..df411919539 100644 --- a/packages/dashmate/src/ssl/zerossl/requestApi.js +++ b/packages/dashmate/src/ssl/zerossl/requestApi.js @@ -1,22 +1,96 @@ import errorDescriptions from './errors/errorDescriptions.js'; +const INVALID_API_KEY_MESSAGE = 'Invalid ZeroSSL API key'; +const INVALID_API_RESPONSE_MESSAGE = 'Invalid ZeroSSL API response'; +const REDACTED_VALUE = '[REDACTED]'; + +/** + * Redact the API key from a parsed ZeroSSL error without mutating the response. + * + * @param {*} value + * @param {string} apiKey + * @returns {*} + */ +function redactApiKey(value, apiKey) { + if (typeof value === 'string') { + return value.replaceAll(apiKey, REDACTED_VALUE); + } + + if (Array.isArray(value)) { + return value.map((item) => redactApiKey(item, apiKey)); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + redactApiKey(key, apiKey), + redactApiKey(item, apiKey), + ]), + ); + } + + return value; +} + +/** + * Build headers with one canonical ZeroSSL authorization value. + * + * @param {string} apiKey + * @param {HeadersInit} sourceHeaders + * @returns {Headers} + */ +function createHeaders(apiKey, sourceHeaders) { + if (typeof apiKey !== 'string' || apiKey.length === 0 || apiKey.trim() !== apiKey) { + throw new Error(INVALID_API_KEY_MESSAGE); + } + + const authorization = `ApiKey ${apiKey}`; + + try { + const headers = new Headers(sourceHeaders); + headers.set('Authorization', authorization); + + if (headers.get('Authorization') !== authorization) { + throw new Error(INVALID_API_KEY_MESSAGE); + } + + return headers; + } catch { + throw new Error(INVALID_API_KEY_MESSAGE); + } +} + /** * Request the ZeroSSL API * + * @param {string} apiKey * @param {string} url * @param {Object} options * @returns {Promise} */ -export default async function requestApi(url, options) { - const response = await fetch(url, options); - const data = await response.json(); +export default async function requestApi(apiKey, url, options) { + const headers = createHeaders(apiKey, options.headers); + const requestOptions = { + ...options, + headers, + }; + + const response = await fetch(url, requestOptions); + + let data; + try { + data = await response.json(); + } catch { + throw new Error(INVALID_API_RESPONSE_MESSAGE); + } if (data.error) { - const errorMessage = errorDescriptions[data.error.code]; + const sanitizedError = redactApiKey(data.error, apiKey); + const errorMessage = errorDescriptions[sanitizedError.code]; - const error = new Error(errorMessage || data.error.type); + const error = new Error(errorMessage || sanitizedError.type); - Object.assign(error, data.error); + Object.assign(error, sanitizedError); throw error; } diff --git a/packages/dashmate/src/ssl/zerossl/revokeCertificate.js b/packages/dashmate/src/ssl/zerossl/revokeCertificate.js index c1fff95cffb..3689789a24c 100644 --- a/packages/dashmate/src/ssl/zerossl/revokeCertificate.js +++ b/packages/dashmate/src/ssl/zerossl/revokeCertificate.js @@ -12,7 +12,7 @@ export default async function revokeCertificate( apiKey, id, ) { - const url = `https://api.zerossl.com/certificates/${id}/revoke?access_key=${apiKey}`; + const url = `https://api.zerossl.com/certificates/${id}/revoke`; const requestOptions = { method: 'POST', @@ -21,5 +21,5 @@ export default async function revokeCertificate( }, }; - return requestApi(url, requestOptions); + return requestApi(apiKey, url, requestOptions); } diff --git a/packages/dashmate/src/ssl/zerossl/verifyDomain.js b/packages/dashmate/src/ssl/zerossl/verifyDomain.js index 4067b4d2cee..a7139fc63fe 100644 --- a/packages/dashmate/src/ssl/zerossl/verifyDomain.js +++ b/packages/dashmate/src/ssl/zerossl/verifyDomain.js @@ -14,7 +14,7 @@ export default async function verifyDomain(id, apiKey) { validation_method: 'HTTP_CSR_HASH', }); - const url = `https://api.zerossl.com/certificates/${id}/challenges?access_key=${apiKey}`; + const url = `https://api.zerossl.com/certificates/${id}/challenges`; const requestOptions = { method: 'POST', @@ -24,5 +24,5 @@ export default async function verifyDomain(id, apiKey) { }, }; - return requestApi(url, requestOptions); + return requestApi(apiKey, url, requestOptions); } diff --git a/packages/dashmate/test/unit/ssl/zerossl/apiRequests.spec.js b/packages/dashmate/test/unit/ssl/zerossl/apiRequests.spec.js new file mode 100644 index 00000000000..8838d3e994b --- /dev/null +++ b/packages/dashmate/test/unit/ssl/zerossl/apiRequests.spec.js @@ -0,0 +1,364 @@ +import qs from 'qs'; +import Certificate from '../../../../src/ssl/zerossl/Certificate.js'; +import cancelCertificate from '../../../../src/ssl/zerossl/cancelCertificate.js'; +import createZeroSSLCertificate from '../../../../src/ssl/zerossl/createZeroSSLCertificate.js'; +import downloadCertificate from '../../../../src/ssl/zerossl/downloadCertificate.js'; +import getCertificate from '../../../../src/ssl/zerossl/getCertificate.js'; +import listCertificates from '../../../../src/ssl/zerossl/listCertificates.js'; +import requestApi from '../../../../src/ssl/zerossl/requestApi.js'; +import revokeCertificate from '../../../../src/ssl/zerossl/revokeCertificate.js'; +import verifyDomain from '../../../../src/ssl/zerossl/verifyDomain.js'; + +const apiKey = 'test-access-key'; +const certificateId = 'certificate-id'; +const certificateData = { + id: certificateId, + type: 1, + status: 'issued', + created: '2026-08-18 10:00:00', + expires: '2026-11-16 10:00:00', + common_name: '127.0.0.1', +}; + +function expectErrorNotToContain(error, values) { + const serializedError = JSON.stringify({ + message: error.message, + stack: error.stack, + cause: error.cause, + properties: Object.fromEntries(Object.entries(error)), + }); + + values.filter(Boolean).forEach((value) => { + expect(serializedError).not.to.contain(value); + }); +} + +describe('ZeroSSL API requests', () => { + let fetchStub; + + beforeEach(function beforeEach() { + fetchStub = this.sinon.stub(globalThis, 'fetch'); + }); + + const requestCases = [ + { + name: 'cancel a certificate', + invoke: () => cancelCertificate(apiKey, certificateId), + url: `https://api.zerossl.com/certificates/${certificateId}/cancel`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + response: { success: 1 }, + expectedResult: { success: 1 }, + }, + { + name: 'create a certificate', + invoke: () => createZeroSSLCertificate('certificate-csr', '127.0.0.1', apiKey), + url: 'https://api.zerossl.com/certificates', + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + expectedBody: { + certificate_domains: '127.0.0.1', + certificate_validity_days: '90', + certificate_csr: 'certificate-csr', + }, + response: certificateData, + assertResult: (result) => { + expect(result).to.be.instanceOf(Certificate); + expect(result.id).to.equal(certificateId); + expect(result.created).to.be.instanceOf(Date); + expect(result.expires).to.be.instanceOf(Date); + }, + }, + { + name: 'download a certificate', + invoke: () => downloadCertificate(certificateId, apiKey), + url: `https://api.zerossl.com/certificates/${certificateId}/download/return`, + method: 'GET', + response: { + 'certificate.crt': 'certificate', + 'ca_bundle.crt': 'ca-bundle', + }, + expectedResult: 'certificate\nca-bundle', + }, + { + name: 'get a certificate', + invoke: () => getCertificate(apiKey, certificateId), + url: `https://api.zerossl.com/certificates/${certificateId}`, + method: 'GET', + response: certificateData, + assertResult: (result) => { + expect(result).to.be.instanceOf(Certificate); + expect(result.id).to.equal(certificateId); + expect(result.created).to.be.instanceOf(Date); + expect(result.expires).to.be.instanceOf(Date); + }, + }, + { + name: 'list certificates with default filters', + invoke: () => listCertificates(apiKey), + url: 'https://api.zerossl.com/certificates?limit=1000&page=1', + method: 'GET', + response: { results: [certificateData] }, + assertResult: (result) => { + expect(result).to.have.length(1); + expect(result[0]).to.be.instanceOf(Certificate); + expect(result[0].id).to.equal(certificateId); + expect(result[0].created).to.be.instanceOf(Date); + expect(result[0].expires).to.be.instanceOf(Date); + }, + }, + { + name: 'list certificates with populated filters', + invoke: () => listCertificates( + apiKey, + ['draft', 'pending_validation'], + 2, + 'search-value', + ), + url: 'https://api.zerossl.com/certificates?limit=1000&page=2&statuses=draft,pending_validation&search=search-value', + method: 'GET', + response: { results: [] }, + expectedResult: [], + }, + { + name: 'revoke a certificate', + invoke: () => revokeCertificate(apiKey, certificateId), + url: `https://api.zerossl.com/certificates/${certificateId}/revoke`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + response: { success: 1 }, + expectedResult: { success: 1 }, + }, + { + name: 'verify a domain', + invoke: () => verifyDomain(certificateId, apiKey), + url: `https://api.zerossl.com/certificates/${certificateId}/challenges`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + expectedBody: { + validation_method: 'HTTP_CSR_HASH', + }, + response: { success: 1 }, + expectedResult: { success: 1 }, + }, + ]; + + requestCases.forEach((requestCase) => { + it(`should use header authentication to ${requestCase.name}`, async function test() { + fetchStub.resolves({ + json: this.sinon.stub().resolves(requestCase.response), + }); + + const result = await requestCase.invoke(); + + expect(fetchStub).to.have.been.calledOnce(); + + const [url, options] = fetchStub.firstCall.args; + const headers = new Headers(options.headers); + const authorizationHeaders = [...headers.entries()] + .filter(([name]) => name.toLowerCase() === 'authorization'); + + expect(url).to.equal(requestCase.url); + expect(url).not.to.contain(apiKey); + expect(url).not.to.contain('access_key'); + expect(options.method).to.equal(requestCase.method); + expect(headers.has('Authorization')).to.equal(true); + expect(headers.get('Authorization')).to.equal(`ApiKey ${apiKey}`); + expect(authorizationHeaders).to.have.length(1); + + if (requestCase.contentType) { + expect(headers.get('Content-Type')).to.equal(requestCase.contentType); + } + + if (requestCase.expectedBody) { + expect(qs.parse(options.body)).to.deep.equal(requestCase.expectedBody); + } else { + expect(options).not.to.have.property('body'); + } + + if (requestCase.assertResult) { + requestCase.assertResult(result); + } else { + expect(result).to.deep.equal(requestCase.expectedResult); + } + }); + }); + + describe('requestApi authentication boundary', () => { + let consoleErrorStub; + let consoleLogStub; + let consoleWarnStub; + + beforeEach(function beforeEach() { + consoleErrorStub = this.sinon.stub(console, 'error'); + consoleLogStub = this.sinon.stub(console, 'log'); + consoleWarnStub = this.sinon.stub(console, 'warn'); + }); + + function expectNoConsoleCalls() { + expect(consoleErrorStub).not.to.have.been.called(); + expect(consoleLogStub).not.to.have.been.called(); + expect(consoleWarnStub).not.to.have.been.called(); + } + + it('should preserve request options without mutating them', async function test() { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Request-ID': 'request-id', + }, + body: 'payload', + redirect: 'manual', + }; + const originalOptions = structuredClone(options); + fetchStub.resolves({ json: this.sinon.stub().resolves({ success: 1 }) }); + + await requestApi(apiKey, 'https://api.zerossl.com/certificates', options); + + const [url, actualOptions] = fetchStub.firstCall.args; + const headers = new Headers(actualOptions.headers); + + expect(url).to.equal('https://api.zerossl.com/certificates'); + expect(actualOptions).not.to.equal(options); + expect(actualOptions.method).to.equal(options.method); + expect(actualOptions.body).to.equal(options.body); + expect(actualOptions.redirect).to.equal(options.redirect); + expect(headers.get('Content-Type')).to.equal('application/x-www-form-urlencoded'); + expect(headers.get('X-Request-ID')).to.equal('request-id'); + expect(headers.get('Authorization')).to.equal(`ApiKey ${apiKey}`); + expect(options).to.deep.equal(originalOptions); + expectNoConsoleCalls(); + }); + + ['authorization', 'AuThOrIzAtIoN'].forEach((headerName) => { + it(`should replace a pre-existing ${headerName} header`, async function test() { + fetchStub.resolves({ json: this.sinon.stub().resolves({ success: 1 }) }); + + await requestApi(apiKey, 'https://api.zerossl.com/certificates', { + method: 'GET', + headers: { [headerName]: 'Bearer competing-credential' }, + }); + + const headers = new Headers(fetchStub.firstCall.args[1].headers); + const authorizationHeaders = [...headers.entries()] + .filter(([name]) => name.toLowerCase() === 'authorization'); + + expect(authorizationHeaders).to.deep.equal([ + ['authorization', `ApiKey ${apiKey}`], + ]); + expectNoConsoleCalls(); + }); + }); + + [ + { name: 'missing', value: undefined }, + { name: 'empty', value: '' }, + { name: 'containing a newline', value: `${apiKey}\ninjected` }, + { name: 'with leading whitespace', value: ` ${apiKey}` }, + { name: 'with a leading tab', value: `\t${apiKey}` }, + { name: 'with trailing whitespace', value: `${apiKey} ` }, + ].forEach(({ name, value }) => { + it(`should reject an API key ${name} before fetching`, async () => { + let error; + + try { + await requestApi(value, 'https://api.zerossl.com/certificates', { + method: 'GET', + headers: {}, + }); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(Error); + expect(error.message).to.equal('Invalid ZeroSSL API key'); + expect(error).not.to.have.own.property('cause'); + expectErrorNotToContain(error, [value]); + expect(fetchStub).not.to.have.been.called(); + expectNoConsoleCalls(); + }); + }); + + it('should replace header-construction errors with a secret-free error', async () => { + let error; + + try { + await requestApi(apiKey, 'https://api.zerossl.com/certificates', { + method: 'GET', + headers: { + 'X-Invalid': `${apiKey}\ninvalid`, + }, + }); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(Error); + expect(error.message).to.equal('Invalid ZeroSSL API key'); + expect(error).not.to.have.own.property('cause'); + expectErrorNotToContain(error, [apiKey]); + expect(fetchStub).not.to.have.been.called(); + expectNoConsoleCalls(); + }); + + it('should redact the API key from parsed ZeroSSL errors before constructing an Error', async function test() { + fetchStub.resolves({ + json: this.sinon.stub().resolves({ + error: { + code: 999, + type: `type ${apiKey}`, + message: `message ${apiKey}`, + details: { + nested: [`details ${apiKey}`], + [`property-${apiKey}`]: 'reflected property name', + }, + }, + }), + }); + + let error; + try { + await requestApi(apiKey, 'https://api.zerossl.com/certificates', { + method: 'GET', + headers: {}, + }); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(Error); + expect(error.code).to.equal(999); + expect(error.message).to.equal('message [REDACTED]'); + expect(error.type).to.equal('type [REDACTED]'); + expect(error.details.nested).to.deep.equal(['details [REDACTED]']); + expect(error.details['property-[REDACTED]']).to.equal('reflected property name'); + expectErrorNotToContain(error, [apiKey]); + expectNoConsoleCalls(); + }); + + it('should replace malformed JSON errors with a generic response error', async function test() { + fetchStub.resolves({ + json: this.sinon.stub().rejects( + new SyntaxError(`Unexpected token in ${apiKey} response`), + ), + }); + + let error; + try { + await requestApi(apiKey, 'https://api.zerossl.com/certificates', { + method: 'GET', + headers: {}, + }); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(Error); + expect(error.message).to.equal('Invalid ZeroSSL API response'); + expect(error).not.to.have.own.property('cause'); + expectErrorNotToContain(error, [apiKey, 'Unexpected token']); + expectNoConsoleCalls(); + }); + }); +});