fix: stop the docker UI from filtering out its own injected API key (403 "Invalid credentials" on self-host) (#6516)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Shuchang Zheng 2026-06-11 09:10:05 -07:00 committed by GitHub
parent def90269c4
commit 4c869489c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 62 additions and 8 deletions

View file

@ -28,8 +28,11 @@ VITE_ENABLE_2FA_NOTIFICATIONS="${VITE_ENABLE_2FA_NOTIFICATIONS:-false}"
# 1. Environment variable (from .env file or docker-compose environment),
# but only if it's not a placeholder/sentinel value. Both the Dockerfile
# default (__SKYVERN_API_KEY_PLACEHOLDER__) and the .env.example default
# (YOUR_API_KEY) can leak through if users skip configuration. Keep this
# filter list aligned with the frontend's PLACEHOLDER_VALUES.
# (YOUR_API_KEY) can leak through if users skip configuration. Keep these
# sentinels aligned with the frontend's placeholder check in
# skyvern-frontend/src/util/env.ts (which matches the Docker sentinel by
# its _PLACEHOLDER__ suffix — the sed below rewrites every occurrence of
# the full sentinel in the bundle, so the frontend must never spell it out).
# 2. Generated credentials file from the backend first-run setup
# (volume-mounted at /app/.skyvern/credentials.toml in docker-compose)
VITE_SKYVERN_API_KEY="${VITE_SKYVERN_API_KEY:-}"

View file

@ -1,3 +1,6 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
async function loadEnv(apiKey: string | null = null, streamingMode = "") {
@ -44,6 +47,53 @@ describe("getCredentialParam", () => {
});
});
describe("getRuntimeApiKey", () => {
// Built via join so this test file never contains the sentinel verbatim
// either — entrypoint-skyvernui.sh sed-replaces every occurrence in built
// assets, and the source guard below must stay meaningful.
const dockerSentinel = ["__SKYVERN_API_KEY", "_PLACEHOLDER__"].join("");
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
window.sessionStorage.clear();
});
it("returns the build-time API key", async () => {
const { getRuntimeApiKey } = await loadEnv("eyJhbGciOiJIUzI1NiJ9.real.key");
expect(getRuntimeApiKey()).toBe("eyJhbGciOiJIUzI1NiJ9.real.key");
});
it("treats the .env.example placeholder as missing", async () => {
const { getRuntimeApiKey } = await loadEnv("YOUR_API_KEY");
expect(getRuntimeApiKey()).toBe(null);
});
it("treats an un-replaced docker placeholder as missing", async () => {
const { getRuntimeApiKey } = await loadEnv(dockerSentinel);
expect(getRuntimeApiKey()).toBe(null);
});
// Regression guard for the v1.0.34v1.0.40 docker auth outage:
// entrypoint-skyvernui.sh sed-replaces EVERY occurrence of the docker
// sentinel in the built bundle with the real API key. When the full
// sentinel appeared verbatim in this module (inside the placeholder
// deny-list), the real key replaced it there too, so the key became a
// member of its own deny-list and the UI sent no x-api-key header at all
// (403 "Invalid credentials" on every request).
it("never spells out the full docker placeholder sentinel in module source", async () => {
const source = await readFile(
path.join(process.cwd(), "src/util/env.ts"),
"utf-8",
);
expect(source.includes(dockerSentinel)).toBe(false);
});
});
describe("browserStreamingMode", () => {
afterEach(() => {
vi.unstubAllEnvs();

View file

@ -80,12 +80,13 @@ function getRuntimeApiKey(): string | null {
// Treat placeholder / example values as missing so they are never sent
// as real credentials (e.g. from .env.example or an un-replaced Docker placeholder).
const PLACEHOLDER_VALUES = [
"YOUR_API_KEY",
"__SKYVERN_API_KEY_PLACEHOLDER__",
];
runtimeApiKey =
candidate && PLACEHOLDER_VALUES.includes(candidate) ? null : candidate;
// The Docker sentinel is matched by its suffix on purpose: the prebuilt-image
// entrypoint sed-replaces every occurrence of the full sentinel in the bundle
// with the real API key, so spelling it out here would put the real key in its
// own deny-list and strip the x-api-key header from every request.
const isPlaceholder =
candidate === "YOUR_API_KEY" || candidate?.endsWith("_PLACEHOLDER__");
runtimeApiKey = candidate && isPlaceholder ? null : candidate;
return runtimeApiKey;
}