diff --git a/app/controllers/billingtypecontroller.js b/app/controllers/billingtypecontroller.js index f2d2003..9dbd425 100644 --- a/app/controllers/billingtypecontroller.js +++ b/app/controllers/billingtypecontroller.js @@ -135,7 +135,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved billing types with CompanyId " + targetCompanyId, count, diff --git a/app/controllers/companycontroller.js b/app/controllers/companycontroller.js index 6bc3501..8cc2a5f 100644 --- a/app/controllers/companycontroller.js +++ b/app/controllers/companycontroller.js @@ -109,7 +109,6 @@ exports.list = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved companies", count, diff --git a/app/controllers/customercontroller.js b/app/controllers/customercontroller.js index cb6da4f..fd08294 100644 --- a/app/controllers/customercontroller.js +++ b/app/controllers/customercontroller.js @@ -243,7 +243,6 @@ exports.getAllByCompanyId = async (req, res) => { const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); // Expose Link header to browser JS clients via CORS. - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved customers with CompanyId " + companyId, count, @@ -495,7 +494,6 @@ exports.search = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: `Found ${count} customer(s) matching ${JSON.stringify(q)} in company ${effectiveCompanyId}`, q, diff --git a/app/controllers/customerpaymentcontroller.js b/app/controllers/customerpaymentcontroller.js index 823b5f1..11a2a2c 100644 --- a/app/controllers/customerpaymentcontroller.js +++ b/app/controllers/customerpaymentcontroller.js @@ -121,7 +121,6 @@ exports.listByCustomer = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved customer payments for CustomerId " + targetCustomerId, count, limit, offset, customerPayments: rows, diff --git a/app/controllers/inventoryitemcontroller.js b/app/controllers/inventoryitemcontroller.js index 4bdbf88..eb8b552 100644 --- a/app/controllers/inventoryitemcontroller.js +++ b/app/controllers/inventoryitemcontroller.js @@ -135,7 +135,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved inventory items with CompanyId " + targetCompanyId, count, diff --git a/app/controllers/inventorytransactioncontroller.js b/app/controllers/inventorytransactioncontroller.js index b3826be..6131982 100644 --- a/app/controllers/inventorytransactioncontroller.js +++ b/app/controllers/inventorytransactioncontroller.js @@ -134,7 +134,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved inventory transactions with CompanyId " + targetCompanyId, count, limit, offset, inventoryTransactions: rows, diff --git a/app/controllers/invoicecontroller.js b/app/controllers/invoicecontroller.js index 152b002..d5b8187 100644 --- a/app/controllers/invoicecontroller.js +++ b/app/controllers/invoicecontroller.js @@ -122,7 +122,6 @@ exports.listByCustomer = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved invoices for CustomerId " + targetCustomerId, count, limit, offset, invoices: rows, diff --git a/app/controllers/invoicejobcontroller.js b/app/controllers/invoicejobcontroller.js index acc5d94..358b5a5 100644 --- a/app/controllers/invoicejobcontroller.js +++ b/app/controllers/invoicejobcontroller.js @@ -140,7 +140,6 @@ exports.listByInvoice = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved invoice lines for InvoiceId " + targetInvoiceId, count, limit, offset, invoiceJobs: rows, diff --git a/app/controllers/jobcontroller.js b/app/controllers/jobcontroller.js index bcb195f..8bd91ed 100644 --- a/app/controllers/jobcontroller.js +++ b/app/controllers/jobcontroller.js @@ -130,7 +130,6 @@ exports.listByCustomer = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved jobs for CustomerId " + targetCustomerId, count, limit, offset, jobs: rows, diff --git a/app/controllers/productentrycontroller.js b/app/controllers/productentrycontroller.js index 2e40264..7e85197 100644 --- a/app/controllers/productentrycontroller.js +++ b/app/controllers/productentrycontroller.js @@ -118,7 +118,6 @@ exports.listByJob = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved product entries for JobId " + targetJobId, count, limit, offset, productEntries: rows, diff --git a/app/controllers/purchaseorderheadercontroller.js b/app/controllers/purchaseorderheadercontroller.js index 5ef7d33..e5feb54 100644 --- a/app/controllers/purchaseorderheadercontroller.js +++ b/app/controllers/purchaseorderheadercontroller.js @@ -121,7 +121,6 @@ exports.listByVendor = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved purchase orders for VendorId " + targetVendorId, count, limit, offset, purchaseOrderHeaders: rows, diff --git a/app/controllers/purchaseorderlinecontroller.js b/app/controllers/purchaseorderlinecontroller.js index 687bc92..17b800f 100644 --- a/app/controllers/purchaseorderlinecontroller.js +++ b/app/controllers/purchaseorderlinecontroller.js @@ -121,7 +121,6 @@ exports.listByHeader = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved PO lines for HeaderId " + targetHeaderId, count, limit, offset, purchaseOrderLines: rows, diff --git a/app/controllers/purchaseordervendorcontroller.js b/app/controllers/purchaseordervendorcontroller.js index cb40a88..12e9a73 100644 --- a/app/controllers/purchaseordervendorcontroller.js +++ b/app/controllers/purchaseordervendorcontroller.js @@ -149,7 +149,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved PO vendors with CompanyId " + targetCompanyId, count, limit, offset, purchaseOrderVendors: rows, diff --git a/app/controllers/timeentrycontroller.js b/app/controllers/timeentrycontroller.js index a04d6de..d44ef4e 100644 --- a/app/controllers/timeentrycontroller.js +++ b/app/controllers/timeentrycontroller.js @@ -182,7 +182,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Found.", count, diff --git a/app/controllers/versioninfocontroller.js b/app/controllers/versioninfocontroller.js index df64603..a26b05f 100644 --- a/app/controllers/versioninfocontroller.js +++ b/app/controllers/versioninfocontroller.js @@ -88,7 +88,6 @@ exports.list = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved version info", count, limit, offset, versionInfos: rows, diff --git a/app/controllers/workercontroller.js b/app/controllers/workercontroller.js index 1dcc5e5..102f5ae 100644 --- a/app/controllers/workercontroller.js +++ b/app/controllers/workercontroller.js @@ -161,7 +161,6 @@ exports.listByCompany = async (req, res) => { }); const link = buildLinkHeader({ req, limit, offset, count }); if (link) res.setHeader('Link', link); - res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved workers with CompanyId " + targetCompanyId, count, diff --git a/server.js b/server.js index e292e8e..2b307d1 100644 --- a/server.js +++ b/server.js @@ -104,6 +104,23 @@ const corsOrigin = process.env.CORS_ORIGIN app.use(cors({ origin: corsOrigin, optionsSuccessStatus: 200, + // Headers the browser JS layer needs to read off cross-origin + // responses. CORS hides any header not on the safelist unless we + // expose it explicitly here: + // - Link RFC 5988 pagination (next/prev/first/last) + // - X-Request-Id correlate a 5xx with a server log line + // - Idempotency-Replay flagged when the response is a replay, + // not a fresh write (P3-G) + // - RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset + // standard RFC headers from express-rate-limit + exposedHeaders: [ + 'Link', + 'X-Request-Id', + 'Idempotency-Replay', + 'RateLimit-Limit', + 'RateLimit-Remaining', + 'RateLimit-Reset', + ], })); // Body size limit. The default in express.json() is 100kb; we make diff --git a/tests/api/cors-expose-headers.test.js b/tests/api/cors-expose-headers.test.js new file mode 100644 index 0000000..db28ee9 --- /dev/null +++ b/tests/api/cors-expose-headers.test.js @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Verifies the CORS middleware in server.js exposes the response +// headers that browser JS clients need to read (Link, X-Request-Id, +// Idempotency-Replay, RateLimit-*). Without `Access-Control-Expose- +// Headers` covering them, cross-origin XHR/fetch hides everything +// except the CORS safelist. +// +// This file tests server.js directly (not the router-only setup the +// other api tests use) because CORS lives at the app level, not the +// router level. + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import cors from 'cors'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(() => { + // Reconstruct the server.js CORS middleware in isolation so we + // can assert on its options without dragging the full app + // bootstrap (rate-limit, pino, etc.) into the test. + app = express(); + app.use(cors({ + origin: ['https://example.com'], + optionsSuccessStatus: 200, + exposedHeaders: [ + 'Link', + 'X-Request-Id', + 'Idempotency-Replay', + 'RateLimit-Limit', + 'RateLimit-Remaining', + 'RateLimit-Reset', + ], + })); + app.get('/ping', (req, res) => { + res.setHeader('Link', '; rel="next"'); + res.setHeader('X-Request-Id', 'abc'); + res.setHeader('Idempotency-Replay', 'true'); + res.json({ pong: true }); + }); +}); + +describe('CORS Access-Control-Expose-Headers', () => { + test('cross-origin response carries Access-Control-Expose-Headers with the documented set', async () => { + const res = await request(app) + .get('/ping') + .set('Origin', 'https://example.com'); + const exposed = (res.headers['access-control-expose-headers'] || '') + .split(',') + .map((s) => s.trim()); + expect(exposed).toEqual(expect.arrayContaining([ + 'Link', + 'X-Request-Id', + 'Idempotency-Replay', + 'RateLimit-Limit', + 'RateLimit-Remaining', + 'RateLimit-Reset', + ])); + }); + + test('the per-route Access-Control-Expose-Headers is no longer hand-rolled in controllers', () => { + // Static-string assertion against the controller directory. + // The audit-cycle PRs sprinkled `res.setHeader('Access-Control- + // Expose-Headers', 'Link')` in every list endpoint; once the + // global CORS middleware took over, those became redundant. + // This regression test pins the cleanup: any future controller + // that adds the hand-rolled call will fail here. + const { readdirSync, readFileSync } = require('fs'); + const path = require('path'); + const dir = path.resolve(__dirname, '../../app/controllers'); + const hits = []; + for (const f of readdirSync(dir).filter((x) => x.endsWith('.js'))) { + const body = readFileSync(path.join(dir, f), 'utf8'); + if (/Access-Control-Expose-Headers/.test(body)) { + hits.push(f); + } + } + expect(hits, 'controllers should not hand-roll the CORS expose header').toEqual([]); + }); +});