Description of the issue
nginx/replace-env-vars.sh substitutes each config key in the built index.html with
an unanchored sed expression:
https://github.com/saleor/saleor-dashboard/blob/main/nginx/replace-env-vars.sh#L11-L27
# nginx/replace-env-vars.sh:11-20
replace_env_var() {
var_name=$1
var_value=$(eval echo \$"$var_name")
if [ -n "$var_value" ]; then
echo "Setting $var_name to: $var_value"
sed -i "s#\([[:space:]]*\)$var_name:[[:space:]]*\"[^\"]*\"#\1$var_name: \"$var_value\"#" "$INDEX_BUNDLE_PATH"
else
echo "No $var_name provided, using defaults."
fi
}
# lines 23-27
replace_env_var "API_URL"
replace_env_var "APP_MOUNT_URI"
replace_env_var "EXTENSIONS_API_URL"
replace_env_var "IS_CLOUD_INSTANCE"
replace_env_var "LOCALE_CODE"
The pattern begins with \([[:space:]]*\), which matches the empty string. It is
therefore not anchored to the start of a line, and API_URL: also matches as a
substring of EXTENSIONS_API_URL:. When replace_env_var "API_URL" runs (line 23),
it rewrites both lines of window.__SALEOR_CONFIG__:
API_URL: "https://example.com/graphql/",
EXTENSIONS_API_URL: "https://example.com/graphql/", <-- collateral damage
replace_env_var "EXTENSIONS_API_URL" runs later (line 25) and would repair the
damage — but only if EXTENSIONS_API_URL is non-empty. On a self-hosted deployment
that intentionally leaves it empty (the supported configuration since #6391, which
falls back to the bundled extensions.json), the script takes the else branch and
the corrupted value survives.
The log output is misleading, because it reports intent rather than result:
Setting API_URL to: https://example.com/graphql/
Setting APP_MOUNT_URI to: /dashboard/
No EXTENSIONS_API_URL provided, using defaults.
…while the served index.html contains
EXTENSIONS_API_URL: "https://example.com/graphql/".
Downstream impact: Extensions → Explore throws for every staff user
useAppStoreExtensions reads that value and derives its fallback flag from whether it
is empty:
https://github.com/saleor/saleor-dashboard/blob/main/src/extensions/views/ExploreExtensions/hooks/useAppStoreExtensions.ts
// useAppStoreExtensions.ts:25-51
const fetchApiExtensions = async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); // line 32
...
};
const isFallback = !appStoreUrl; // line 42
const categories = appStoreUrl
? await fetchApiExtensions(appStoreUrl) // line 50
: await loadFallbackExtensions(); // line 51
With the corrupted value the chain is:
appStoreUrl is the GraphQL endpoint, so isFallback is false and the bundled
catalogue is never used;
fetch() on that URL returns 200 with an HTML body (the GraphQL playground),
so response.ok is true and the !response.ok guard does not fire;
response.json() throws a SyntaxError, which is caught and stored in error;
ExploreExtensions rethrows it as a render-time error
(ExploreExtensions.tsx:23-26 — if (error) { throw new Error(error); }),
so the whole view is replaced by the generic error page.
Result: Extensions → Explore is unusable for every staff user on any self-hosted
deployment that leaves EXTENSIONS_API_URL empty — which is exactly the configuration
the static fallback was added for.
Steps to reproduce the problem
Self-contained reproduction (no Docker needed — the function below is copied verbatim
from nginx/replace-env-vars.sh):
#!/bin/sh
set -e
cd "$(mktemp -d)"
# index.html as emitted by the build from src/index.html
cat > index.html <<'EOF'
<!doctype html>
<html>
<head>
<script>
window.__SALEOR_CONFIG__ = {
API_URL: "",
APP_MOUNT_URI: "/dashboard/",
STATIC_URL: "/dashboard/",
EXTENSIONS_API_URL: "",
IS_CLOUD_INSTANCE: "",
SALEOR_CLOUD_APP_DOMAIN: "",
LOCALE_CODE: "EN",
};
</script>
</head>
<body><div id="dashboard-app"></div></body>
</html>
EOF
# verbatim from nginx/replace-env-vars.sh (line 16)
replace_env_var() {
var_name=$1
var_value=$(eval echo \$"$var_name")
if [ -n "$var_value" ]; then
echo "Setting $var_name to: $var_value"
sed -i "s#\([[:space:]]*\)$var_name:[[:space:]]*\"[^\"]*\"#\1$var_name: \"$var_value\"#" index.html
else
echo "No $var_name provided, using defaults."
fi
}
# typical self-hosted deployment: EXTENSIONS_API_URL deliberately empty
API_URL="https://example.com/graphql/"
APP_MOUNT_URI="/dashboard/"
EXTENSIONS_API_URL=""
IS_CLOUD_INSTANCE=""
LOCALE_CODE="EN"
for v in API_URL APP_MOUNT_URI EXTENSIONS_API_URL IS_CLOUD_INSTANCE LOCALE_CODE; do
replace_env_var "$v"
done
echo "--- result ---"
grep -n 'API_URL' index.html
Output:
Setting API_URL to: https://example.com/graphql/
Setting APP_MOUNT_URI to: /dashboard/
No EXTENSIONS_API_URL provided, using defaults.
No IS_CLOUD_INSTANCE provided, using defaults.
Setting LOCALE_CODE to: EN
--- result ---
6: API_URL: "https://example.com/graphql/",
9: EXTENSIONS_API_URL: "https://example.com/graphql/",
End-to-end, in a container built from the repo Dockerfile:
- Run the Dashboard image with
API_URL set and EXTENSIONS_API_URL unset/empty.
curl the served /index.html and read window.__SALEOR_CONFIG__ —
EXTENSIONS_API_URL equals API_URL.
- Sign in and open Extensions → Explore — the view renders the generic error page.
What did you expect to happen?
replace_env_var "API_URL" should only rewrite the API_URL entry. When
EXTENSIONS_API_URL is unset, it should remain "" and useAppStoreExtensions should
take the isFallback path and render the bundled catalogue with the self-hosted
banner.
Additional information
Proposed fix
Anchor the pattern to the start of the line. window.__SALEOR_CONFIG__ is emitted one
key per line by src/index.html, so ^ is sufficient and minimal:
--- a/nginx/replace-env-vars.sh
+++ b/nginx/replace-env-vars.sh
@@ -13,7 +13,7 @@ replace_env_var() {
var_value=$(eval echo \$"$var_name")
if [ -n "$var_value" ]; then
echo "Setting $var_name to: $var_value"
- sed -i "s#\([[:space:]]*\)$var_name:[[:space:]]*\"[^\"]*\"#\1$var_name: \"$var_value\"#" "$INDEX_BUNDLE_PATH"
+ sed -i "s#^\([[:space:]]*\)$var_name:[[:space:]]*\"[^\"]*\"#\1$var_name: \"$var_value\"#" "$INDEX_BUNDLE_PATH"
else
echo "No $var_name provided, using defaults."
fi
Verified against the reproduction above with both GNU sed 4.9 and BusyBox sed 1.36.1
(the runtime image is nginx:stable-alpine, i.e. BusyBox):
EXTENSIONS_API_URL empty → stays "", API_URL still set correctly;
EXTENSIONS_API_URL set → still replaced correctly (no regression on the
Saleor Cloud path).
If relying on line-per-key formatting feels too fragile, an equivalent alternative is
to require a boundary before the key, e.g. matching { or a line start explicitly, or
generating the config block from the environment instead of patching it in place.
Related hardening (optional, separate concern)
fetchApiExtensions treats any 2xx as valid JSON
(useAppStoreExtensions.ts:25-36). Checking Content-Type, or falling back to the
bundled catalogue when parsing fails instead of rethrowing into the error page, would
turn a misconfiguration into a degraded-but-usable screen rather than a hard failure.
That would also have made this bug far easier to diagnose.
Notes for triage
- Not reproducible when
EXTENSIONS_API_URL is set (the later call repairs the value),
which is presumably why it has not surfaced on Saleor Cloud.
API_URL is currently the only key that is a proper suffix of another key in the
replace list, so it is the only one that collides today — but the pattern would
collide again for any future *_API_URL-style key.
Environment
Browser and version: N/A (container entrypoint / served index.html)
OS and version: Linux, Docker image based on nginx:stable-alpine (BusyBox sed 1.36.1)
Dashboard version: 3.23.16 (self-hosted); script unchanged on main @ 3.23.22
Core version: Saleor 3.23
Description of the issue
nginx/replace-env-vars.shsubstitutes each config key in the builtindex.htmlwithan unanchored
sedexpression:https://github.com/saleor/saleor-dashboard/blob/main/nginx/replace-env-vars.sh#L11-L27
The pattern begins with
\([[:space:]]*\), which matches the empty string. It istherefore not anchored to the start of a line, and
API_URL:also matches as asubstring of
EXTENSIONS_API_URL:. Whenreplace_env_var "API_URL"runs (line 23),it rewrites both lines of
window.__SALEOR_CONFIG__:replace_env_var "EXTENSIONS_API_URL"runs later (line 25) and would repair thedamage — but only if
EXTENSIONS_API_URLis non-empty. On a self-hosted deploymentthat intentionally leaves it empty (the supported configuration since #6391, which
falls back to the bundled
extensions.json), the script takes theelsebranch andthe corrupted value survives.
The log output is misleading, because it reports intent rather than result:
…while the served
index.htmlcontainsEXTENSIONS_API_URL: "https://example.com/graphql/".Downstream impact: Extensions → Explore throws for every staff user
useAppStoreExtensionsreads that value and derives its fallback flag from whether itis empty:
https://github.com/saleor/saleor-dashboard/blob/main/src/extensions/views/ExploreExtensions/hooks/useAppStoreExtensions.ts
With the corrupted value the chain is:
appStoreUrlis the GraphQL endpoint, soisFallbackisfalseand the bundledcatalogue is never used;
fetch()on that URL returns 200 with an HTML body (the GraphQL playground),so
response.okistrueand the!response.okguard does not fire;response.json()throws aSyntaxError, which is caught and stored inerror;ExploreExtensionsrethrows it as a render-time error(
ExploreExtensions.tsx:23-26—if (error) { throw new Error(error); }),so the whole view is replaced by the generic error page.
Result: Extensions → Explore is unusable for every staff user on any self-hosted
deployment that leaves
EXTENSIONS_API_URLempty — which is exactly the configurationthe static fallback was added for.
Steps to reproduce the problem
Self-contained reproduction (no Docker needed — the function below is copied verbatim
from
nginx/replace-env-vars.sh):Output:
End-to-end, in a container built from the repo
Dockerfile:API_URLset andEXTENSIONS_API_URLunset/empty.curlthe served/index.htmland readwindow.__SALEOR_CONFIG__—EXTENSIONS_API_URLequalsAPI_URL.What did you expect to happen?
replace_env_var "API_URL"should only rewrite theAPI_URLentry. WhenEXTENSIONS_API_URLis unset, it should remain""anduseAppStoreExtensionsshouldtake the
isFallbackpath and render the bundled catalogue with the self-hostedbanner.
Additional information
Proposed fix
Anchor the pattern to the start of the line.
window.__SALEOR_CONFIG__is emitted onekey per line by
src/index.html, so^is sufficient and minimal:Verified against the reproduction above with both GNU sed 4.9 and BusyBox sed 1.36.1
(the runtime image is
nginx:stable-alpine, i.e. BusyBox):EXTENSIONS_API_URLempty → stays"",API_URLstill set correctly;EXTENSIONS_API_URLset → still replaced correctly (no regression on theSaleor Cloud path).
If relying on line-per-key formatting feels too fragile, an equivalent alternative is
to require a boundary before the key, e.g. matching
{or a line start explicitly, orgenerating the config block from the environment instead of patching it in place.
Related hardening (optional, separate concern)
fetchApiExtensionstreats any2xxas valid JSON(
useAppStoreExtensions.ts:25-36). CheckingContent-Type, or falling back to thebundled catalogue when parsing fails instead of rethrowing into the error page, would
turn a misconfiguration into a degraded-but-usable screen rather than a hard failure.
That would also have made this bug far easier to diagnose.
Notes for triage
EXTENSIONS_API_URLis set (the later call repairs the value),which is presumably why it has not surfaced on Saleor Cloud.
API_URLis currently the only key that is a proper suffix of another key in thereplace list, so it is the only one that collides today — but the pattern would
collide again for any future
*_API_URL-style key.Environment