Security audit rules
Auditable security rules for Front-Commerce projects, covering rate limiting, CSP, permissions, input handling, and secret management.
These rules describe the security posture expected from a production
Front-Commerce project. Each rule is written to be checked mechanically where
possible: rules marked static can be verified from the repository alone,
runtime rules require a running application (local or deployed), and
manual rules require human judgment. Use the rule codes (AUDIT-SEC-NN) to
reference findings in audit reports.
AUDIT-SEC-01 — Configure rate limiting for sensitive endpoints
Severity: critical — Detection: static + runtime
Rule: Every endpoint that can be abused (login, registration, password
reset, contact forms, search, payment callbacks, bulk APIs) is protected by the
RateLimiter service, with limits deliberately chosen for the project's real
traffic.
Why: Without rate limiting, credential stuffing, spam, and scraping run
unthrottled against the store and its backend. The opposite failure also
happens: limits copied verbatim from documentation examples (max: 5 per
minute) have blocked legitimate users in production because nobody validated
them against actual traffic.
How to check:
- Verify the service is configured:
front-commerce.config.tsmust contain arateLimiterentry with a Redis configuration. If absent, the service is unusable and this rule fails for any project with public forms. - Find the call sites:
grep -rn "limitHTTPResource\|limitGraphQLResource\|limitRateByGraphQLResolver" app extensions
- List routes that mutate state and check coverage:
Every authentication, account, and form-submission route in that list should appear in the call sites from step 2, or be covered by a shared guard.grep -rln "export const action\|export async function action" app/routes extensions
- Flag any limit that is exactly
{ max: 5, duration: "1m" }(the documentation example) and ask the team for the traffic analysis that justifies it. A copied default with no rationale is a finding. - Runtime: replay 10 rapid requests against a protected endpoint and confirm an
HTTP 429response; confirm normal browsing never triggers it.
AUDIT-SEC-02 — Enforce a Content Security Policy without violations
Severity: important — Detection: static + runtime
Rule: The application ships a restrictive CSP (no __dangerouslyDisable, no
permanently loose reportOnlyDirectives), and browsing the key pages produces
zero CSP violations.
Why: CSP is the main mitigation against XSS and injected third-party
scripts. A policy that is disabled, overly permissive, or violated in practice
(a real audit found an img-src violation in production) either provides no
protection or silently breaks content for users.
How to check:
- Locate the CSP provider (typically
app/config/cspProvider.ts) and confirm it is registered infront-commerce.config.ts. - Static red flags:
grep -rn "__dangerouslyDisable" app extensionsgrep -rn "reportOnlyDirectives" app extensions
__dangerouslyDisable: trueis a violation.reportOnlyDirectivesis acceptable only as a documented, temporary migration step. - Verify each directive lists only domains the project actually uses (payment,
analytics, fonts, media). Wildcards like
*orhttps:onscriptSrcare findings. - Runtime: browse the home page, a category page, a product page, the cart, and
the checkout with the browser console open. Any
Refused to load ... because it violates the following Content Security Policy directivemessage is a violation. Also inspect the application'ssecuritylogger output, which records violations server-side.
AUDIT-SEC-03 — Enforce permissions at all three levels: UI, HTTP, and business
Severity: critical — Detection: static + manual
Rule: Every restricted feature is guarded at three levels: the UI hides it
(usePermissions / <Restricted>), the HTTP layer rejects unauthorized
requests (loader/middleware), and the business layer (GraphQL resolver or loader
service) throws when the caller lacks the permission. Hiding a button is never
the only guard.
Why: The UI check only affects what is rendered. Anyone can call the route
or the GraphQL field directly with curl. A feature guarded only in React is
effectively public.
How to check:
- Inventory the client-side guards:
grep -rn "usePermissions\|<Restricted" app extensions
- For each permission name found (for example
acme.feature), verify a server-side counterpart exists:A permission checked client-side but never server-side is a violation.grep -rn "isAllowedTo(\"acme.feature\")" app extensions - Verify the server-side checks live in the right places: Remix loaders and
actions (
app.user.permissions.isAllowedTo(...)throwing a404/errorResponse) and GraphQLcontextEnhancers or resolvers. - Verify sensitive permissions are registered with
serverOnly: trueso they are never serialized to the client. - Manual: pick one restricted feature, log in as a user without the permission, and call its route and GraphQL field directly. Both must fail.
AUDIT-SEC-04 — Guard actions with the same checks as their loaders, and restrict HTTP methods
Severity: critical — Detection: static
Rule: Every authorization, ownership, or feature-flag check present in a
route's loader is also enforced in its action (ideally through a shared
guard function), and every action rejects HTTP methods it does not support
with 405 Method Not Allowed.
Why: Actions are reachable by direct POST/PUT requests regardless of
what the loader rendered. A loader that redirects unauthorized users does
nothing to stop a crafted curl call to the action. Similarly, an action
without a method guard silently accepts any verb.
How to check:
- List route modules exporting both a loader and an action:
grep -rln "export const action\|export async function action" app/routes extensions
- For each file, compare the first statements of the loader and the action. Any check in the loader (authentication, permission, feature flag) that does not appear in the action — directly or via a shared guard — is a violation.
- Check for method guards:
An action that handles form submissions without branching ongrep -rn "request.method" app/routes extensions
request.method(or rejecting unexpected verbs with a405) is a finding. - Resource routes (no default export) nested under a protected layout deserve special attention: they do not run the parent layout's loader, so they need their own guard.
AUDIT-SEC-05 — Never return 401 from the public API; prefer 404 over 403 for inaccessible resources
Severity: important — Detection: static + runtime
Rule: Public API responses never use the 401 status code, and endpoints
serving per-customer resources return 404 — not 403 — when the resource
exists but belongs to someone else.
Why: A 401 response triggers the browser's HTTP authentication prompt when
the application sits behind an HTTP auth layer (common on staging), and can log
users out of that layer. A 403 on an existing-but-foreign resource confirms
that the ID is valid, enabling IDOR enumeration of order numbers, quote IDs, and
similar identifiers.
How to check:
- Static scan for both status codes in project code:
grep -rn "status: 401\|, 401)\|status: 403\|, 403)" app extensions
- Every
401in a public route or resolver is a violation (use404or a redirect to the login page instead). - Every
403on a resource that has a per-customer owner (orders, quotes, disputes, addresses) is a violation: the ownership check must collapse into the same404as "does not exist". - Runtime: authenticate as customer A, request one of customer B's resources by
ID (
/api/.../<id>or the GraphQL field). The response must be404, indistinguishable from a nonexistent ID.
AUDIT-SEC-06 — Never expose stack traces or technical details in error responses
Severity: important — Detection: runtime
Rule: Error pages and API error responses (404, 500, GraphQL errors) never reveal stack traces, file paths, dependency versions, or backend URLs to the client in production mode.
Why: Technical details in error output map the internals of the application for an attacker: framework versions to match against CVEs, file paths, and backend topology. It also degrades the user experience with unstyled or unintelligible pages.
How to check:
- Run the application in production mode (
NODE_ENV=production, production build) — development mode intentionally shows more. - Request a nonexistent URL (
/does-not-exist) and a malformed dynamic route (/product/<script>or an invalid ID). Confirm a branded error page renders with no stack trace or file path in the HTML source. - Trigger a server error (for example, point a backend URL at an unreachable
host in a staging copy) and confirm the 500 page and the GraphQL response
contain no
at /home/...frames, no SQL, and no upstream URLs. - Static support check: confirm the project exports error boundaries —
A missinggrep -rn "ErrorBoundary" app/routes app/root.tsx
RootErrorBoundarymeans unhandled errors fall through to raw output.
AUDIT-SEC-07 — Keep server-only code in .server.ts files
Severity: critical — Detection: static
Rule: Every module that reads secrets, holds API tokens, or wraps a
privileged backend client uses the .server.(t|j)s suffix (or lives in a
*.server directory) so the bundler excludes it from client bundles.
Why: Without the suffix, a single import from a React component is enough to pull server code — and the secrets it references — into the JavaScript shipped to every visitor's browser.
How to check:
- Find modules referencing secrets outside
.serverfiles:Review each hit: references to non-grep -rln "process.env" app extensions --include="*.ts" --include="*.tsx" | grep -v ".server."FRONT_COMMERCE_WEB_*variables in a non-.serverfile are violations (see AUDIT-INFRA-04 for the environment variable rule itself). - Search for credential-shaped code outside
.serverfiles:grep -rln "apiKey\|clientSecret\|Authorization.*Bearer\|privateKey" app extensions | grep -v ".server." - Build the project and scan the client output for known secret values or
variable names:
Any match containing a non-grep -rl "FRONT_COMMERCE_" build/client/assets | head
WEB_variable or a literal secret is a confirmed leak.
AUDIT-SEC-08 — Sanitize untrusted input before injecting it into headers, redirects, or hrefs
Severity: critical — Detection: static
Rule: Any string originating from user input (form fields, query parameters,
CMS content, backend data entered by customers) is validated or escaped before
being written into an HTTP header value, a redirect target, an href/src
attribute, or a Content-Disposition filename. Headers strip CR/LF; URLs are
allow-listed to http:/https:.
Why: Raw user strings in these positions open header injection (CRLF
splitting), open redirects used in phishing, and javascript:/data: XSS
through links. Allow-listing schemes is the only robust defense; deny-lists miss
encodings.
How to check:
- Header injection candidates:
Any template-string interpolation of a request-derived value without agrep -rn "setHeader\|Content-Disposition" app extensions
replace(/[\r\n"]/g, ...)-style sanitizer is a violation. - Open-redirect candidates:
A redirect target built fromgrep -rn "redirect(" app extensions | grep -iv "\"/"
searchParams, form data, or any external value must be validated against the site's own origin or a fixed allow-list. - XSS through links:
Anygrep -rn "href={" app extensions | grep -v "href={\`/\|href={\"/"
hreffed by user or CMS data must go through a scheme check that accepts onlyhttp:andhttps:(parse withnew URL()and compareprotocol). - Also review
dangerouslySetInnerHTMLoccurrences: content must come from a sanitizer or a trusted WYSIWYG pipeline.
AUDIT-SEC-09 — Never log or commit secrets
Severity: critical — Detection: static
Rule: Secrets (API tokens, passwords, private keys, maintenance-mode tokens) appear only in environment variables: never in committed files, never in log or debug statements, and never hardcoded in source.
Why: Git history is forever — a committed .env stays retrievable after
deletion. Logs flow to monitoring tools, support tickets, and third-party
aggregators; a logged token is a shared token.
How to check:
- Committed environment files:
Onlygit ls-files | grep -E "(^|/)\.env"git log --all --diff-filter=A --name-only -- "*.env" "*/.env"
.env.dist-style templates without real values are acceptable. A real.envanywhere in history requires secret rotation, not just deletion. - Hardcoded credentials:
Review each hit; a literal value that works against a real service is a violation.grep -rniE "(api[_-]?key|secret|password|token)\s*[:=]\s*[\"'][^\"']{8,}" app extensions front-commerce.config.ts
- Secrets in logs:
Logging a received or expected credential — even at debug level — is a violation. Log lengths or hashes instead.grep -rn "console.log\|logger\.\|debug(" app extensions | grep -iE "token|secret|password|authorization"
AUDIT-SEC-10 — Cap bulk inputs to prevent resource exhaustion
Severity: important — Detection: static
Rule: Every endpoint that accepts a list of identifiers (SKUs, product IDs,
log entries) or fans out one request into many backend calls enforces an
explicit upper bound and fails fast with a 400 above it.
Why: A loader that loops over an arbitrary user-provided collection is a denial-of-service primitive against the store and its backend: one request with 10,000 SKUs can fetch the whole catalog or exhaust backend connections.
How to check:
- Find list-shaped inputs:
grep -rn "getAll(\|\.split(\"," app/routes extensions
- Find fan-out patterns over request-derived arrays:
grep -rn "Promise.all\|Promise.allSettled" app/routes extensions
- For each hit fed by
requestdata, verify a length guard precedes the fan-out (for exampleif (skus.length > 20) throw new Response(..., { status: 400 })). A loop over unbounded user input is a violation. - Pay special attention to generic proxy routes or middlewares: a route that forwards a caller-controlled URL or collection to a backend must be scoped to a single upstream pattern and bounded.
AUDIT-SEC-11 — Guard project-defined admin endpoints with contribution mode and isAllowedTo
Severity: critical — Detection: static
Rule: If the project exposes admin or contributor endpoints, every admin
Remix route lives under api.admin.*, every admin GraphQL query lives under
Query.admin, mutations are named admin* at the root, and every leaf
resolver, loader, and action calls isAllowedTo("admin.<domain>...") in
addition to the framework's contribution-mode baseline.
Why: Front-Commerce auto-enforces the contribution-mode baseline only for
surfaces matching the convention (/api/admin/* routes, Query.admin,
top-level admin* mutations). An admin endpoint placed outside these patterns
gets no automatic guard, and a conventional endpoint without its leaf permission
check lets any contributor reach any domain's destructive operations.
How to check:
- Skip this rule if the project defines no admin surface:
grep -rln "api.admin\|Query.admin\|isAllowedTo(\"admin" app extensions || echo "no admin surface"
- Find admin-looking endpoints outside the convention: search route filenames
and GraphQL typedefs for
admin,preview,contribution, or back-office wording that is not underapi.admin.*/Query.admin. Each one bypasses the automatic guard and is a violation. - For every file under
routes/api.admin.*, confirm it referencesisAllowedTo(at least once:Files printed by this loop are violations.for f in $(find app extensions -name "api.admin.*"); do grep -L "isAllowedTo(" "$f"; done - Confirm the admin permissions are registered in
onServerServicesInitwithserverOnly: true, and that no legacyuser.journey.isAuthorizedTo(...)calls remain.