StingwardSecurity audit

Project security audit

my-saas

Serious problems found. Fix the critical and high items before you launch or bring in more users.

Target
./my-saas
Date
October 22, 2026
Type
Project
Findings
4 critical, 6 high, 7 medium, 1 low
AI review
18 confirmed
Tool
Stingward 0.1.7

Generated by Stingward 0.1.7, open source (Apache 2.0). github.com/HiveControlAgents/StingWard

1Executive summary

Summary

This app works, but several of its problems could be used against it today. Fix the OpenAI key first: it is in a variable that ships to every visitor's browser, so anyone can copy it and spend your money. Right after that, take out the agent route that runs the AI's answer as a command, because it lets a stranger run anything on your server. The invoice query, the PDF download and the link preview each trust what the visitor sends, and the old next and axios versions need an update.

GradeF

What the grade means

The grade goes from A to F and depends only on the most serious findings. After the AI review, false positives and what was fixed do not count.

  1. AOnly low items, or none.
  2. BMedium items, nothing high or critical.
  3. COne or two high items, nothing critical.
  4. DThree or more high items, nothing critical.
  5. FAt least one critical item.

AI review: 18 confirmed.

Fix these first

  1. Any visitor to your site can copy NEXT_PUBLIC_OPENAI_API_KEY and use it as you: run up the bill for that service or reach the data the key protects.

    Where:.env:1

  2. Any visitor to your site can copy NEXT_PUBLIC_OPENAI_API_KEY and use it as you: run up the bill for that service or reach the data the key protects.

    Where:app/api/chat/route.ts:3

  3. A hidden instruction in any page, file or message the model reads becomes a command on your server or in your users' browsers.

    Where:app/api/agent/route.ts:16

2Remediation plan

Work from top to bottom. Effort is a rough estimate for someone who knows the code.

Quick: under an hour. Hours: fits in a day. Days: more than a day, usually because something has to be retested or rewritten.

#ProblemWhereEffort
Critical: fix now
1Secret exposed to the browser: NEXT_PUBLIC_OPENAI_API_KEY.env:1Hours
2Secret exposed to the browser: NEXT_PUBLIC_OPENAI_API_KEYapp/api/chat/route.ts:3Hours
3Text written by the model reaches a shell commandapp/api/agent/route.ts:16Hours
4next 14.1.0: 36 known vulnerabilities; worst is CVE-2025-29927 (Authorization Bypass in Next.js Middleware)package-lock.json:15Days
High: this week
5AI SDK running in the browser (dangerouslyAllowBrowser)app/api/chat/route.ts:3Hours
6Table profiles has no RLS in Supabasesupabase/migrations/0001_profiles.sql:1Hours
7SQL query built from request inputapp/api/invoices/route.ts:7Hours
8File path built from request input (fs.readFileSync())app/api/invoices/pdf/route.ts:6Hours
9Server fetches a URL taken from the request (fetch())app/api/preview/route.ts:4Hours
10axios 1.6.0: 31 known vulnerabilities; worst is CVE-2026-25639 (Axios is Vulnerable to Denial of Service via __proto__ Key in mergeConfig)package-lock.json:19Hours
Medium: this month
11.env is not in .gitignore.envQuick
12Supabase policy open to everyone: read invoices (select)supabase/migrations/0001_profiles.sql:9Hours
13CORS open to any websiteapp/api/chat/route.ts:8Quick
14Text written by the model reaches the page as HTMLcomponents/Chat.tsx:6Hours
15AI endpoint with no login and no rate limitapp/api/agent/route.ts:7Hours
16AI endpoint with no login and no rate limitapp/api/chat/route.ts:5Hours
17requests >=2.19.0: the lowest version it allows (2.19.0) has 5 known vulnerabilities; worst is CVE-2018-18074 (Insufficiently Protected Credentials in Requests)worker/requirements.txt:2Hours
Low: when you can
18requirements.txt has no lockfile: 2 dependency(ies) with open versionsworker/requirements.txtQuick

3Findings in detail

Excerpts read from the files at audit time. Secrets are masked.

Application 10

#1CriticalConfirmedRule APP001

Secret exposed to the browser: NEXT_PUBLIC_OPENAI_API_KEY

Where
  • .env:1
Review

Your OpenAI key is in a variable whose name starts with NEXT_PUBLIC_, and Next.js copies those into the code every visitor's browser downloads. Anyone can take the key from there and spend your OpenAI credit. Create a new key, delete this one in OpenAI, and save the new one as OPENAI_API_KEY, without the prefix.

Evidence
.env line 1
1NEXT_PUBLIC_OPENAI_API_KEY=[REDACTED:secret_field]
Why it matters

Variables prefixed with NEXT_PUBLIC_ are bundled into the JavaScript every visitor downloads.

How to fix

Drop the NEXT_PUBLIC_ prefix, use it server-side only (API route, server action, edge function) and rotate the key.

Standards
#2CriticalConfirmedRule APP001

Secret exposed to the browser: NEXT_PUBLIC_OPENAI_API_KEY

Where
  • app/api/chat/route.ts:3
Review

The chat route reads the key from that same variable, so renaming it in .env is not enough on its own. Change this line to read OPENAI_API_KEY once the key has its new name.

Evidence
app/api/chat/route.ts line 3
2
3const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, dangerouslyAllowBrowser: true });
4
Why it matters

Variables prefixed with NEXT_PUBLIC_ are bundled into the JavaScript every visitor downloads.

How to fix

Drop the NEXT_PUBLIC_ prefix, use it server-side only (API route, server action, edge function) and rotate the key.

Standards
#3CriticalConfirmedRule APP009

Text written by the model reaches a shell command

Where
  • app/api/agent/route.ts:16
Review

Whatever the AI answers is run as a command on your server, and the person who types the task decides much of that answer. Someone can ask for a chore that deletes files or sends your keys away, and the server will do it. Remove the exec call and let the agent choose only from a short list of fixed actions.

Evidence
app/api/agent/route.ts line 16
15 });
16 exec(reply.choices[0].message.content ?? "");
17 return Response.json({ ok: true });
Why it matters

The model's answer can be steered by anything it read (a web page, a file, someone's message). Using it as is turns a prompt injection into commands running on your server.

How to fix

Treat model output like user input: map the answer to commands from a fixed list and pass arguments as a list, without a shell.

Standards
#5HighConfirmedRule APP002

AI SDK running in the browser (dangerouslyAllowBrowser)

Where
  • app/api/chat/route.ts:3
Review

This option tells the OpenAI library it may run in the browser, where the key would be visible to everyone. This file runs on the server, so remove dangerouslyAllowBrowser: it only turns off the warning that protects you.

Evidence
app/api/chat/route.ts line 3
2
3const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, dangerouslyAllowBrowser: true });
4
Why it matters

Your OpenAI/Anthropic key ships to the browser. Someone copies it and runs up your bill.

How to fix

Call the AI API from a server route and keep the key there.

Standards
#6HighConfirmedRule APP004

Table profiles has no RLS in Supabase

Where
  • supabase/migrations/0001_profiles.sql:1
Review

The profiles table keeps your users' emails and plans, and without row level security (Supabase's rules for who can see each row) anyone with your app's public Supabase key can read or change all of them. Turn row level security on for this table and add a policy that lets each user see only their own row.

Evidence
supabase/migrations/0001_profiles.sql line 1
1create table public.profiles (
2 id uuid primary key references auth.users,
Why it matters

Without Row Level Security anyone with the anon key (which is public) can read and change this table.

How to fix

Add: alter table profiles enable row level security; and create policies for each operation.

Standards
#12MediumConfirmedRule APP005

Supabase policy open to everyone: read invoices (select)

Where
  • supabase/migrations/0001_profiles.sql:9
Review

This rule lets anyone, even people who are not logged in, read every invoice. Change it so each user reads only the invoices whose user_id is their own.

Evidence
supabase/migrations/0001_profiles.sql line 9
8alter table public.invoices enable row level security;
9create policy "read invoices" on public.invoices for select using (true);
Why it matters

The policy uses true as its condition, so it applies to anyone, signed in or not.

How to fix

Replace true with a condition such as auth.uid() = user_id.

Standards
#13MediumConfirmedRule APP003

CORS open to any website

Where
  • app/api/chat/route.ts:8
Review

This answer tells browsers that any website may call your chat route, so another site can use its visitors to spend your OpenAI credit. Remove the Access-Control-Allow-Origin header, or set it to your own site's address.

Evidence
app/api/chat/route.ts line 8
7 const reply = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] });
8 return new Response(JSON.stringify(reply), { headers: { "Access-Control-Allow-Origin": "*" } });
9}
Why it matters

Any page on the internet can call your API from a visitor's browser.

How to fix

Allow only your frontend domains in origin/allow_origins.

Standards
#14MediumConfirmedRule APP009

Text written by the model reaches the page as HTML

Where
  • components/Chat.tsx:6
Review

Chat messages, the AI's answers included, are put on the page as HTML. If someone gets the AI to answer with a script, it runs in your users' browsers and can act as them. Show the messages as plain text, or clean them with a sanitizer first.

Evidence
components/Chat.tsx line 6
5 const { messages } = useChat();
6 return messages.map((m) => <div key={m.id} dangerouslySetInnerHTML={{ __html: m.content }} />);
7}
Why it matters

The model's answer can be steered by anything it read (a web page, a file, someone's message). Using it as is turns a prompt injection into scripts running in your users' browsers.

How to fix

Treat model output like user input: show it as text, or convert the Markdown and clean it with DOMPurify.sanitize before inserting it.

Standards
#15MediumConfirmedRule APP011

AI endpoint with no login and no rate limit

Where
  • app/api/agent/route.ts:7
Review

Anyone on the internet can call this route as often as they like, and each call costs you an OpenAI request on top of running a command on your server. Require a login and limit how many requests each user can make per minute.

Evidence
app/api/agent/route.ts line 7
6// The user describes a chore in plain words and the agent does it on the server.
7export async function POST(req: Request) {
8 const { task } = await req.json();
Why it matters

Anyone who finds this URL can call the model on your account in a loop: the bill spikes and your app becomes a free AI proxy for others.

How to fix

Require a signed-in user, rate limit per user or IP (@upstash/ratelimit, express-rate-limit, slowapi), set max_tokens / maxOutputTokens, and add a spending limit in the provider's dashboard.

Standards
#16MediumConfirmedRule APP011

AI endpoint with no login and no rate limit

Where
  • app/api/chat/route.ts:5
Review

The chat route also works with no login and no limit, so a script can use your OpenAI account for free until the bill arrives. Add the same login check and per-user limit here.

Evidence
app/api/chat/route.ts line 5
4
5export async function POST(req: Request) {
6 const { prompt } = await req.json();
Why it matters

Anyone who finds this URL can call the model on your account in a loop: the bill spikes and your app becomes a free AI proxy for others.

How to fix

Require a signed-in user, rate limit per user or IP (@upstash/ratelimit, express-rate-limit, slowapi), set max_tokens / maxOutputTokens, and add a spending limit in the provider's dashboard.

Standards

Dependencies 4

#4CriticalConfirmedRule DEP001

next 14.1.0: 36 known vulnerabilities; worst is CVE-2025-29927 (Authorization Bypass in Next.js Middleware)

Where
  • package-lock.json:15
Review

Your app is locked to an old version of next with many published security problems. The worst one lets someone skip the checks that middleware (code that runs before each page) is meant to do. Update next to the version in the fix below, then check that the app still works.

Evidence
package-lock.json line 15
14 },
15 "node_modules/next": {
16 "version": "14.1.0",
Why it matters

This version has public vulnerabilities. Others: CVE-2024-46982, CVE-2026-75604, GHSA-2464-fhr4-w9xw, GHSA-2xp9-vwfh-vxw4, CVE-2024-34351 and 30 more. Probability CVE-2025-29927 gets exploited in the next 30 days: 99% (EPSS).

How to fix

Upgrade next to 15.5.28 or later.

Known vulnerabilities in this version
IDSeveritySummaryFixed in
CVE-2025-29927CriticalAuthorization Bypass in Next.js MiddlewareEPSS 99%14.2.25
CVE-2024-46982CriticalNext.js Cache PoisoningEPSS 59%14.2.10
CVE-2026-75604CriticalNext.js: Unauthenticated Remote Code Execution on windows-hosted serversEPSS 2%15.5.24
GHSA-2464-fhr4-w9xwCriticalNext.js: Unauthenticated Remote Code Execution in Image Optimization API when SVG files...15.5.28
GHSA-2xp9-vwfh-vxw4CriticalNext.js: Unauthenticated Remote Code Execution in Image Optimization API when AVIF file...15.5.24
CVE-2024-34351HighNext.js Server-Side Request Forgery in Server ActionsEPSS 5%14.1.1
CVE-2024-51479HighNext.js authorization bypass vulnerabilityEPSS 4%14.2.15
CVE-2026-44578HighNext.js vulnerable to server-side request forgery in applications using WebSocket upgradesEPSS 2%15.5.16

and 28 more

Standards
#10HighConfirmedRule DEP001

axios 1.6.0: 31 known vulnerabilities; worst is CVE-2026-25639 (Axios is Vulnerable to Denial of Service via __proto__ Key in mergeConfig)

Where
  • package-lock.json:19
Review

Your project is locked to an old version of axios with published security problems, and the worst one can make your app stop answering. Update axios to the version in the fix below.

Evidence
package-lock.json line 19
18 },
19 "node_modules/axios": {
20 "version": "1.6.0",
Why it matters

This version has public vulnerabilities. Others: CVE-2024-39338, CVE-2025-58754, CVE-2026-44495, CVE-2026-44496, CVE-2026-44494 and 25 more.

How to fix

Upgrade axios to 1.20.0 or later.

Known vulnerabilities in this version
IDSeveritySummaryFixed in
CVE-2026-25639HighAxios is Vulnerable to Denial of Service via __proto__ Key in mergeConfigEPSS 2%1.13.5
CVE-2024-39338HighServer-Side Request Forgery in axiosEPSS 1%1.7.4
CVE-2025-58754HighAxios is vulnerable to DoS attack through lack of data size checkEPSS 1%1.12.0
CVE-2026-44495Highaxios Vulnerable to Credential Theft and Response Hijacking via Prototype Pollution Gad...EPSS 1%1.15.2
CVE-2026-44496HighAxios: Regular Expression Denial of Service (ReDoS) via Cookie Name Injection1.16.0
CVE-2026-44494Highaxios Vulnerable to Full Man-in-the-Middle via Prototype Pollution Gadget in `config.pr...1.16.0
CVE-2026-42033HighAxios: Prototype Pollution Gadgets - Response Tampering, Data Exfiltration, and Request...1.15.1
CVE-2025-27152Highaxios Requests Vulnerable To Possible SSRF and Credential Leakage via Absolute URL1.8.2

and 23 more

Standards
#17MediumConfirmedRule DEP004

requests >=2.19.0: the lowest version it allows (2.19.0) has 5 known vulnerabilities; worst is CVE-2018-18074 (Insufficiently Protected Credentials in Requests)

Where
  • worker/requirements.txt:2
Review

This line lets an install keep a version of requests from 2018 with known security problems. Raise the minimum to the version in the fix below, or better, lock exact versions, as the lockfile finding explains.

Evidence
worker/requirements.txt line 2
1openai>=1.30
2requests>=2.19.0
Why it matters

Without a lockfile the range is the only rule: a machine that already has version 2.19.0 keeps it, because it fits, and an install can pick it to satisfy other packages. Other issues in version 2.19.0: CVE-2023-32681, CVE-2024-47081, CVE-2024-35195, CVE-2026-25645.

How to fix

Raise the minimum to 2.33.0: replace requests>=2.19.0 with requests>=2.33.0. The next install then upgrades older copies.

Known vulnerabilities in the lowest version allowed
IDSeveritySummaryFixed in
CVE-2018-18074HighInsufficiently Protected Credentials in RequestsEPSS 7%2.20.0
CVE-2023-32681MediumUnintended leak of Proxy-Authorization header in requestsEPSS 3%2.31.0
CVE-2024-47081MediumRequests vulnerable to .netrc credentials leak via malicious URLs2.32.4
CVE-2024-35195MediumRequests `Session` object does not verify requests after making first request with veri...2.32.0
CVE-2026-25645MediumRequests has Insecure Temp File Reuse in its extract_zipped_paths() utility function2.33.0
Standards
#18LowConfirmedRule DEP003

requirements.txt has no lockfile: 2 dependency(ies) with open versions

Where
  • worker/requirements.txt (the whole file)
Review

Without a lockfile (a file that pins the exact version of every package), each install of the worker can get different versions, a broken or tampered one included. Create one with a tool such as uv or pip-tools and keep it in the project.

Why it matters

Without a lockfile nobody knows which version gets installed, so it cannot be audited.

How to fix

Generate and commit a lockfile (npm install, poetry lock, uv lock) or pin versions with ==.

Standards

Code 3

#7HighConfirmedRule COD001

SQL query built from request input

Where
  • app/api/invoices/route.ts:7
Review

The user value from the address goes straight into the database query, so a visitor can rewrite the query and read every customer's invoices. Have the query send that value separately (a parameterized query), so the database treats it as data and never as part of the command.

Evidence
app/api/invoices/route.ts line 7
6 const user = new URL(req.url).searchParams.get("user");
7 const { rows } = await pool.query(`select * from invoices where user_id = '${user}'`);
8 return Response.json(rows);
Why it matters

The value goes straight into the SQL text. Someone who sends ' OR '1'='1 can read, change or delete data.

How to fix

Pass values as parameters and let the driver combine them: cursor.execute("... WHERE id = %s", (id,)), $1 in Postgres, ? in SQLite, sql`...` or $queryRaw`...` in Prisma. A column or table name cannot be a parameter: pick it from a fixed list (SORTS[key], if order not in ALLOWED: abort(400)).

Standards
#8HighConfirmedRule COD007

File path built from request input (fs.readFileSync())

Where
  • app/api/invoices/pdf/route.ts:6
Review

The file name comes from the address and is joined to the folder as it is, so a name like ../.env lets anyone download files outside invoices, your keys included. Keep only the file's own name and check that the invoice belongs to the person who is logged in.

Evidence
app/api/invoices/pdf/route.ts line 6
5 const file = new URL(req.url).searchParams.get("file") ?? "";
6 const pdf = fs.readFileSync(path.join(process.cwd(), "invoices", file));
7 return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
Why it matters

A value like ../../.env lets someone read or overwrite any file the server can reach.

How to fix

Keep only the file name (os.path.basename, secure_filename, path.basename), or resolve the path and check that it stays inside the folder you expect.

Standards
#9HighConfirmedRule COD008

Server fetches a URL taken from the request (fetch())

Where
  • app/api/preview/route.ts:4
Review

Your server opens any address a visitor sends, including addresses that only the server can reach, such as internal services or the cloud's own address that hands out credentials. Accept only public web links and refuse private and internal addresses before fetching.

Evidence
app/api/preview/route.ts line 4
3 const { url } = await req.json();
4 const page = await fetch(url);
5 const html = await page.text();
Why it matters

Someone can make your server call internal addresses, such as the cloud metadata service (169.254.169.254), and get back the answer with credentials.

How to fix

Allow only the domains you need, block private and local addresses, and do not follow redirects without checking them.

Standards

Secrets 1

#11MediumConfirmedRule SEC003

.env is not in .gitignore

Where
  • .env (the whole file)
Review

Your .gitignore does not list .env, so the file with your keys is easy to send to GitHub by accident. Add a line with .env to .gitignore, and if the file was ever committed, replace every key in it.

Why it matters

It has not been committed yet, but the next git add . will take the keys along.

How to fix

Add .env to .gitignore.

Standards

4Scope and method

What was checked

AI review

After the automated scan, an AI read the code of each finding and marked each one as confirmed or false positive. When in doubt, the finding stayed confirmed. The review's comment appears next to each finding.

How the review changes the grade

Sources consulted

SourceUsed for Status
OSV.devknown vulnerabilities and malicious packagesconsulted
CISA KEVvulnerabilities attackers are exploiting nowconsulted, catalog 2026.10.21
EPSS (FIRST)likelihood of exploitation in the next 30 daysconsulted
NVDseverity scores missing from advisoriesnot needed: every advisory had a score

Only package names and versions went to these services. For the AI review, the code excerpt of each finding, with secrets masked, was sent to the AI model.

How it was done

  1. Listed the target's files, skipping dependency and build folders such as node_modules and dist. In a third-party skill the build output is read, since that is what runs.
  2. Read the lockfiles and asked OSV.dev about every package and version. Vulnerabilities on CISA's KEV list become critical; EPSS and NVD add the likelihood of exploitation and missing scores.
  3. Ran 51 rules for secrets, common AI-built app mistakes, flaws in the project's own code, agent and MCP settings, and skill content. Test folders, docs and examples count less.
  4. An AI read the code of each finding and marked the outcome: confirmed or false positive.
  5. Graded the result from A to F by the most serious findings that still stand after the review.

Limitations