diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f0681..eda3373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Bump @octokit/rest to ^22.0.1. Refs STCLI-280. * Commit `yarn.lock` to avoid future supply chain attacks. Refs STCLI-281. * *BREAKING* bump `engines.node` to v22. Refs STCLI-XXX. +* Add capability to attach a Content-Security-Policy to locally served, existing build. Refs STCLI-286. ## [4.0.0](https://github.com/folio-org/stripes-cli/tree/v4.0.0) (2025-02-24) [Full Changelog](https://github.com/folio-org/stripes-cli/compare/v3.2.0...v4.0.0) diff --git a/lib/commands/serve.js b/lib/commands/serve.js index 8f78c6f..c3a6f51 100644 --- a/lib/commands/serve.js +++ b/lib/commands/serve.js @@ -115,6 +115,10 @@ module.exports = { type: 'string', conflicts: 'configFile', }) + .option('csp-file', { + describe: 'Path to a text file containing a Content-Security-Policy header value to apply when serving', + type: 'string', + }) .option('mirage [scenario]', { describe: 'Enable Mirage Server when available and optionally specify a scenario', type: 'string', diff --git a/lib/server.js b/lib/server.js index c22f765..625dc66 100644 --- a/lib/server.js +++ b/lib/server.js @@ -51,9 +51,25 @@ function start(dir, options) { const app = express(); app.use(express.json()); app.use(cors()); + if (options.cspFile) { + const cspFilePath = path.resolve(options.cspFile); + if (!fs.existsSync(cspFilePath)) { + console.error(`CSP file "${options.cspFile}" does not exist.`); + return; + } + const cspValue = fs.readFileSync(cspFilePath, 'utf8').trim(); + app.use((req, res, next) => { + res.set('Content-Security-Policy-Report-Only', cspValue); + next(); + }); + } app.use(express.static(dir, {})); app.use(logger('tiny')); + const resolvedPath = path.resolve(dir); + app.get('*', (req, res) => { + res.sendFile(path.join(resolvedPath, 'index.html')); + }); const server = http.createServer(app); // Perform some basic checks to ensure we have a directory with something to serve diff --git a/test/server.spec.js b/test/server.spec.js new file mode 100644 index 0000000..dfd6747 --- /dev/null +++ b/test/server.spec.js @@ -0,0 +1,62 @@ +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const expect = require('chai').expect; +const server = require('../lib/server'); + +describe('The server module', function () { + beforeEach(function () { + this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stripes-cli-server-test-')); + fs.writeFileSync(path.join(this.tmpDir, 'index.html'), ''); + this.httpCreateServerSpy = this.sandbox.spy(http, 'createServer'); + }); + + afterEach(function () { + fs.rmSync(this.tmpDir, { recursive: true, force: true }); + }); + + describe('csp-file option', function () { + it('applies the CSP header from the supplied file when serving', function (done) { + const cspValue = "default-src 'self'"; + const cspFilePath = path.join(this.tmpDir, 'csp.txt'); + fs.writeFileSync(cspFilePath, `${cspValue}\n`); + + server.start(this.tmpDir, { port: 0, cspFile: cspFilePath }); + + const httpServer = this.httpCreateServerSpy.returnValues[0]; + httpServer.on('listening', () => { + const { port } = httpServer.address(); + http.get({ hostname: 'localhost', port, path: '/' }, (res) => { + expect(res.headers['content-security-policy-report-only']).to.equal(cspValue); + res.resume(); + httpServer.close(done); + }); + }); + }); + + it('does not set a CSP header when the option is omitted', function (done) { + server.start(this.tmpDir, { port: 0 }); + + const httpServer = this.httpCreateServerSpy.returnValues[0]; + httpServer.on('listening', () => { + const { port } = httpServer.address(); + http.get({ hostname: 'localhost', port, path: '/' }, (res) => { + expect(res.headers['content-security-policy-report-only']).to.be.undefined; + res.resume(); + httpServer.close(done); + }); + }); + }); + + it('logs an error and does not start the server when the csp file does not exist', function () { + this.sandbox.spy(console, 'error'); + const missingCspPath = path.join(this.tmpDir, 'missing-csp.txt'); + + server.start(this.tmpDir, { port: 0, cspFile: missingCspPath }); + + expect(console.error).to.have.been.calledWithMatch(`CSP file "${missingCspPath}" does not exist.`); + expect(this.httpCreateServerSpy).to.not.have.been.called; + }); + }); +});