Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ Instantiates middleware. See an [example](https://github.com/firebase/superstati

* `options` - Optional configuration:
* `fallthrough` - When `false`, render a 404 page from within Superstatic rather than calling through to the next middleware. Defaults to `true`.
* `config` - A file path to your application's configuration file (see [Configuration](#configuration)) or an object containing your application's configuration. If an object is provided, it will be merged into existing config in a `superstatic.json`.
* `config` - A file path to your application's configuration file (see [Configuration](#configuration)) or an object containing your application's configuration. If an object is provided, it will be merged into existing config in `superstatic.json` or `firebase.json`.
* `autoConfig` - When `false`, skip discovery of `superstatic.json` and `firebase.json`. Use this with a `config` object to avoid inheriting headers, rewrites, or other settings from those files. An explicit file path in `config` is still loaded. Defaults to `true`.
* `protect` - Adds HTTP basic auth. Example: `username:password`
* `env`- A file path your application's environment variables file or an object containing values that are made available at the urls `/__/env.json` and `/__/env.js`. See the documentation detail on [environment variables](http://docs.firebase.com/guides/environment-variables).
* `cwd` - The current working directory to set as the root. Your application's `public` configuration option will be used relative to this.
Expand Down
16 changes: 7 additions & 9 deletions src/loaders/config-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,27 @@ const { isPlainObject } = require("../utils/objectutils");

const CONFIG_FILE = ["superstatic.json", "firebase.json"];

module.exports = function (filename) {
module.exports = function (filename, autoConfig = true) {
const defaultFiles = autoConfig ? CONFIG_FILE : [];
if (typeof filename === "function") {
return filename;
}

filename = filename ?? CONFIG_FILE;
filename = filename ?? defaultFiles;

let configObject = {};
let config = {};

// From custom config data passed in
try {
configObject = JSON.parse(filename);
if (isPlainObject(configObject)) {
filename = defaultFiles;
}
} catch {
if (isPlainObject(filename)) {
configObject = filename;
filename = CONFIG_FILE;
filename = defaultFiles;
}
}
Comment on lines 46 to 51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a bug in how stringified JSON config objects (e.g., '{\"public\": \"app\"}') are handled.

Currently, if a stringified JSON object is passed, JSON.parse(filename) succeeds, so configObject is populated. However, filename remains the original JSON string. Because it is a string, it bypasses the catch block, and later fails the isPlainObject(filename) check on line 58. As a result, the default config files are never loaded or merged, which contradicts the intended behavior described in the comments.

Furthermore, even if isPlainObject(filename) on line 58 were to evaluate to true, setting filename = defaultFiles (an array) at that point is too late because the Array.isArray(filename) resolution block has already executed, meaning the array would never be resolved to an actual file path.

To fix both issues, we should set filename = defaultFiles immediately when a stringified JSON object is successfully parsed in the try block.

Here is the recommended refactoring for the parsing block:

try {
  configObject = JSON.parse(filename);
  if (isPlainObject(configObject)) {
    filename = defaultFiles;
  }
} catch {
  if (isPlainObject(filename)) {
    configObject = filename;
    filename = defaultFiles;
  }
}

This ensures that both plain objects and stringified JSON objects are correctly identified and set to defaultFiles before the array resolution logic runs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I normalized parsed JSON objects before filename resolution in 5edcfd0. They now use the same default-file merge path as object configs, while autoConfig: false still bypasses discovery. Supplied values override defaults in both forms.


Expand All @@ -52,12 +56,6 @@ module.exports = function (filename) {
});
}

// Set back to default config file if stringified object is
// given as config. With this, we override values in the config file
if (isPlainObject(filename)) {
filename = CONFIG_FILE;
}

// A file name or array of file names
if (typeof filename === "string" && filename.endsWith("json")) {
try {
Expand Down
2 changes: 2 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { Configuration } from "./config";
export interface MiddlewareOptions {
fallthrough?: boolean;
config?: string | Configuration;
/** Whether to discover default config files. Defaults to true. */
autoConfig?: boolean;
protect?: string;
env?: string | Record<string, string>;
cwd?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/superstatic.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const superstatic = function (spec = {}) {

// Load data
/** @type {import("./config").Configuration} */
const config = (spec.config = loadConfigFile(spec.config));
const config = (spec.config = loadConfigFile(spec.config, spec.autoConfig));
config.errorPage = config.errorPage ?? "/404.html";

// Set up provider
Expand Down
88 changes: 88 additions & 0 deletions test/integration/config-loading.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2026 Google LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import * as fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect } from "chai";
import connect from "connect";
import request from "supertest";

import superstatic from "../../src/";

describe("explicit middleware configuration", () => {
let originalCwd: string;
let directory: string;

beforeEach(async () => {
originalCwd = process.cwd();
directory = await fs.mkdtemp(join(tmpdir(), "superstatic-config-"));
await fs.mkdir(join(directory, "public"));
await fs.writeFile(
join(directory, "public", "index.html"),
"explicit config",
);
process.chdir(directory);
});

afterEach(async () => {
process.chdir(originalCwd);
await fs.rm(directory, { recursive: true, force: true });
});

for (const filename of ["superstatic.json", "firebase.json"]) {
it(`does not inherit headers or rewrites from ${filename} with autoConfig disabled`, async () => {
const fileConfig = {
headers: [
{ source: "**", headers: [{ key: "X-Autoloaded", value: "yes" }] },
],
rewrites: [{ source: "**", destination: "/index.html" }],
};
await fs.writeFile(
filename,
JSON.stringify(
filename === "firebase.json" ? { hosting: fileConfig } : fileConfig,
),
);
const options = {
autoConfig: false,
fallthrough: false,
cwd: directory,
config: {
public: "public",
redirects: [{ source: "/to-index", destination: "/index.html" }],
},
};
const app = connect().use(superstatic(options));

const response = await request(app)
.get("/index.html")
.expect(200)
.expect("explicit config");
expect(response.headers).not.to.have.property("x-autoloaded");
await request(app)
.get("/to-index")
.expect(301)
.expect("Location", "/index.html");
await request(app).get("/missing").expect(404);
});
}
});
87 changes: 87 additions & 0 deletions test/unit/loaders/config-file.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,91 @@ describe("loading config files", () => {
await fs.rm("firebase.json");
});
});
describe("automatic config discovery", () => {
let originalCwd;

beforeEach(() => {
originalCwd = process.cwd();
process.chdir(".tmp");
});

afterEach(() => {
process.chdir(originalCwd);
});

for (const filename of ["superstatic.json", "firebase.json"]) {
it(`does not merge ${filename} into an explicit object`, async () => {
const fileConfig = { public: "default", headers: [{ source: "**" }] };
await fs.writeFile(
filename,
JSON.stringify(
filename === "firebase.json" ? { hosting: fileConfig } : fileConfig,
),
);

expect(loadConfigFile({ public: "app" }, false)).to.eql({
public: "app",
});
expect(loadConfigFile(JSON.stringify({ public: "app" }), false)).to.eql(
{
public: "app",
},
);
expect(loadConfigFile({}, false)).to.eql({});
expect(loadConfigFile(undefined, false)).to.eql({});
});

for (const autoConfig of [undefined, true]) {
it(`still merges ${filename} when autoConfig is ${autoConfig}`, async () => {
const fileConfig = { public: "default", cleanUrls: true };
await fs.writeFile(
filename,
JSON.stringify(
filename === "firebase.json"
? { hosting: fileConfig }
: fileConfig,
),
);

expect(loadConfigFile({ public: "app" }, autoConfig)).to.eql({
public: "app",
cleanUrls: true,
});
});

it(`merges a stringified object with ${filename} when autoConfig is ${autoConfig}`, async () => {
const fileConfig = { public: "default", cleanUrls: true };
await fs.writeFile(
filename,
JSON.stringify(
filename === "firebase.json"
? { hosting: fileConfig }
: fileConfig,
),
);

expect(
loadConfigFile(JSON.stringify({ public: "app" }), autoConfig),
).to.eql({
public: "app",
cleanUrls: true,
});
});
}
}

it("still loads an explicit config filename", async () => {
await fs.writeFile(
"custom.json",
JSON.stringify({ hosting: { public: "app" } }),
);
expect(loadConfigFile("custom.json", false)).to.eql({ public: "app" });
});

it("still loads a stringified config object", () => {
expect(loadConfigFile(JSON.stringify({ public: "app" }), false)).to.eql({
public: "app",
});
});
Comment on lines +204 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that stringified JSON config objects are correctly merged with the default config files when autoConfig is enabled, we should add a test case for that scenario. Currently, there is no test verifying this behavior, and due to the bug in src/loaders/config-file.js where isPlainObject(filename) is checked instead of isPlainObject(configObject), this merging is silently failing.

    it("still loads a stringified config object", () => {
      expect(loadConfigFile(JSON.stringify({ public: "app" }), false)).to.eql({
        public: "app",
      });
    });

    it("merges a stringified config object with default config files when autoConfig is true", async () => {
      await fs.writeFile(
        "superstatic.json",
        JSON.stringify({ cleanUrls: true }),
      );
      try {
        expect(loadConfigFile(JSON.stringify({ public: "app" }), true)).to.eql({
          public: "app",
          cleanUrls: true,
        });
      } finally {
        await fs.rm(
          "superstatic.json",
          { force: true },
        );
      }
    });

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added four failing-before/passing-after cases covering both default filenames with autoConfig omitted or explicitly true, plus coverage for JSON strings with discovery disabled. All 18 loader tests pass, and the full Linux suite passes with 247 tests and 15 pending. Build and lint also pass.

});
});
Loading