SharePointVault ~/gotchas/azure-functions ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ public-mode-flag-opens-every-endpoint.md
sharepoint-vault›gotchas›azure-functions›public-mode-flag-opens-every-endpoint.mdhistoryedit on GitHub
● GOTCHAazure-functions· 3 min read

A "public mode" flag opens every endpoint, not just the chat

applies to: Azure Functions (Node.js v4 model) shared between an authenticated app and an anonymous public instancereviewed: 2026-11-13#azure-functions#security#authentication#azure-openai#chatbot

When one Function App codebase runs both behind a signed-in SPFx app and as an anonymous chatbot on a public website, the usual trick is an app setting such as PUBLIC_MODE=true that switches the token check off on the public instance. That flag is read inside the shared auth helper — so it switches the check off for every HTTP function in the app, not just /api/chat. Treat the public instance as its own product: list the endpoints it really needs, return 404 from all others, and let a test fail the build when a new endpoint forgets the gate.

Když táž Function App slouží přihlášené SPFx aplikaci i anonymnímu chatu na webu a veřejná instance vypne kontrolu tokenu příznakem typu PUBLIC_MODE=true, vypne ji pro všechny HTTP funkce v aplikaci, ne jen pro chat. Veřejná instance má mít vlastní seznam endpointů, zbytek 404 – a test, který shodí build, když nový endpoint bránu zapomene.

Symptom

Nothing visible. The website chat works; a penetration test of the public instance then shows, with nothing but the allowed Origin header:

  • GET /api/usage returns the monthly usage summary (requests, tokens per day),
  • the storage-backed endpoints built for the signed-in app answer 200 for reads, and their write routes need no token either,
  • POST /api/extractPdf accepts a base64 PDF for parsing from anyone,
  • an endpoint that fetches URLs is closed only because its allowlist app setting happens to be empty.

The Origin check does not help: it stops a browser on a foreign page, not curl -H "Origin: https://contoso.com".

Cause

The mode flag is checked in one place — the auth helper (authMode() returns "off" when public) — which is exactly what you want for the chat. But every other function calls the same helper, and none of them was written with an anonymous caller in mind. A second, quieter version of the same trap sits in the request contract: the public chat accepted the full contract of the signed-in app — model selection, max_tokens up to the app maximum, message arrays with image_url parts, 50 messages of 8,000 characters, a conversation ending with an assistant turn — none of which the website widget ever sends.

Fix

Give the public instance an explicit allowlist and gate everything else first, before origin and auth:

// publicMode.ts
export function isPublicMode(env = process.env): boolean {
  const v = String(env.PUBLIC_MODE || '').trim().toLowerCase();
  return v === 'true' || v === '1';
}

/** The public instance serves only chat and version. */
export function publicModeRejection(cors: Record<string, string>, env = process.env) {
  return isPublicMode(env) ? { status: 404, headers: cors, jsonBody: { error: 'Not found' } } : null;
}

// usage.ts, extractPdf.ts, … — first line after OPTIONS
const publicGate = publicModeRejection(corsHeaders);
if (publicGate) { return publicGate; }

Enforce it with a test that reads the sources, so the next endpoint cannot forget:

// test/publicModeEndpoints.test.mjs
const PUBLIC_ALLOWED = new Set(['chat.ts', 'version.ts']);
for (const f of readdirSync(SRC).filter(f => f.endsWith('.ts'))) {
  const s = readFileSync(join(SRC, f), 'utf8');
  if (!/app\.http\(/.test(s) || PUBLIC_ALLOWED.has(f)) continue;
  const gate = s.indexOf('publicModeRejection(');
  const origin = s.indexOf("originRejection(request.headers.get('origin')");
  assert.ok(gate !== -1 && (origin === -1 || gate < origin), `${f}: missing public-mode gate`);
}

And give the public chat its own narrow contract derived from what the widget sends: text-only user/assistant messages, last one from the user and short, history trimmed from the back to a fixed count and character total, a small body cap (tens of kB, not the tens of MB the signed-in app needs for attachments), and client tuning parameters (model, maxTokens, temperature) ignored rather than clamped. Don't echo the deployment name in the response either — the widget doesn't need it.

Notes

  • Rate limits held in process memory (a Map per instance) reset on every restart and multiply with scale-out. On an anonymous endpoint the real spend ceiling is the Azure OpenAI deployment's TPM quota plus a budget alert on the resource — check both.
  • Prompt rules ("never write code", "answer in the user's language") are probabilistic; they belong in the prompt, but the request contract and endpoint list are the deterministic part — test those.
  • Related: Server-side Origin check rejects your own smoke test, Rate limit counts the capability probe.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-11-13last updated: 2026-11-13historyedit on GitHubview source