diff --git a/.changeset/reject-prefixed-server-env-keys.md b/.changeset/reject-prefixed-server-env-keys.md new file mode 100644 index 0000000..dd27b37 --- /dev/null +++ b/.changeset/reject-prefixed-server-env-keys.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-solid': patch +--- + +`start.env` now rejects `server` schema keys that carry the public env prefix at config time. Vite bakes every `VITE_`-prefixed variable (or whatever `envPrefix` selects) into the browser's `import.meta.env` regardless of which side of the schema declares it, so `server: { VITE_API_SECRET: ... }` silently shipped the secret to every client through Vite's own channel — with no diagnostics, since the leak scan only watches the virtual server module's values. The prefix rule was previously enforced one-way (client keys must have it); the reverse guard now fails fast with a rename message, mirroring the existing client-side guard. diff --git a/examples/start-env/env.serverprefix.ts b/examples/start-env/env.serverprefix.ts new file mode 100644 index 0000000..33e4491 --- /dev/null +++ b/examples/start-env/env.serverprefix.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +// Fixture for the config-time server-prefix guard +// (ENV_SCHEMA=./env.serverprefix.ts): Vite bakes every VITE_-prefixed var +// into the browser's import.meta.env regardless of schema side, so a +// prefixed key under `server` can never stay secret and must be rejected +// before anything builds. +export default { + server: { + VITE_API_SECRET: z.string().min(8), + }, +}; diff --git a/examples/start-env/test/run.mjs b/examples/start-env/test/run.mjs index 8f93c48..464efb4 100644 --- a/examples/start-env/test/run.mjs +++ b/examples/start-env/test/run.mjs @@ -365,6 +365,12 @@ async function guardsMode() { out = await build({ ENV_SCHEMA: './env.badprefix.ts' }).catch((e) => e.message); record('guards', 'prefix', 'non-VITE_ client key is a config-time error', /must carry the public env prefix/.test(out) && /APP_NAME/.test(out), out.slice(0, 400)); + + // The reverse: Vite bakes every VITE_-prefixed var into the browser's + // import.meta.env regardless of schema side, so a prefixed SERVER key + // is a leak the moment it exists — reject it before anything builds. + out = await build({ ENV_SCHEMA: './env.serverprefix.ts' }).catch((e) => e.message); + record('guards', 'prefix', 'VITE_-prefixed server key is a config-time error', /cannot keep it secret/.test(out) && /VITE_API_SECRET/.test(out), out.slice(0, 400)); } async function prodMode() { diff --git a/src/start-env.ts b/src/start-env.ts index a6b8aab..e47351c 100644 --- a/src/start-env.ts +++ b/src/start-env.ts @@ -247,6 +247,27 @@ function assertSchemaShape( ); } } + // The reverse guard: Vite itself bakes every prefixed variable into + // `import.meta.env` for the browser, so declaring one under `server` + // cannot keep it secret — it leaks through Vite's channel with no + // diagnostics from this plugin's leak scan (which only watches the + // virtual server module's values). + for (const key of Object.keys(typed.server ?? {})) { + const prefix = envPrefixes.find((p) => key.startsWith(p)); + if (prefix) { + const bare = key.slice(prefix.length); + throw new Error( + `[vite-plugin-solid] server env var "${key}" in ${envFile} carries the public ` + + `env prefix "${prefix}". Vite exposes every "${prefix}"-prefixed variable to ` + + `the browser through import.meta.env no matter which side declares it, so a ` + + `\`server\` entry cannot keep it secret. ` + + (bare + ? `Rename it to "${bare}" (in the schema and in your .env/environment), or ` + : `Rename it without the prefix, or `) + + `move it to \`client\` if it is public.`, + ); + } + } return typed; }