Skip to main content
Version: next

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:

  1. Verify the service is configured: front-commerce.config.ts must contain a rateLimiter entry with a Redis configuration. If absent, the service is unusable and this rule fails for any project with public forms.
  2. Find the call sites:
    grep -rn "limitHTTPResource\|limitGraphQLResource\|limitRateByGraphQLResolver" app extensions
  3. List routes that mutate state and check coverage:
    grep -rln "export const action\|export async function action" app/routes extensions
    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.
  4. 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.
  5. Runtime: replay 10 rapid requests against a protected endpoint and confirm an HTTP 429 response; 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:

  1. Locate the CSP provider (typically app/config/cspProvider.ts) and confirm it is registered in front-commerce.config.ts.
  2. Static red flags:
    grep -rn "__dangerouslyDisable" app extensions
    grep -rn "reportOnlyDirectives" app extensions
    __dangerouslyDisable: true is a violation. reportOnlyDirectives is acceptable only as a documented, temporary migration step.
  3. Verify each directive lists only domains the project actually uses (payment, analytics, fonts, media). Wildcards like * or https: on scriptSrc are findings.
  4. 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 directive message is a violation. Also inspect the application's security logger 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:

  1. Inventory the client-side guards:
    grep -rn "usePermissions\|<Restricted" app extensions
  2. For each permission name found (for example acme.feature), verify a server-side counterpart exists:
    grep -rn "isAllowedTo(\"acme.feature\")" app extensions
    A permission checked client-side but never server-side is a violation.
  3. Verify the server-side checks live in the right places: Remix loaders and actions (app.user.permissions.isAllowedTo(...) throwing a 404/error Response) and GraphQL contextEnhancers or resolvers.
  4. Verify sensitive permissions are registered with serverOnly: true so they are never serialized to the client.
  5. 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:

  1. List route modules exporting both a loader and an action:
    grep -rln "export const action\|export async function action" app/routes extensions
  2. 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.
  3. Check for method guards:
    grep -rn "request.method" app/routes extensions
    An action that handles form submissions without branching on request.method (or rejecting unexpected verbs with a 405) is a finding.
  4. 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:

  1. Static scan for both status codes in project code:
    grep -rn "status: 401\|, 401)\|status: 403\|, 403)" app extensions
  2. Every 401 in a public route or resolver is a violation (use 404 or a redirect to the login page instead).
  3. Every 403 on a resource that has a per-customer owner (orders, quotes, disputes, addresses) is a violation: the ownership check must collapse into the same 404 as "does not exist".
  4. Runtime: authenticate as customer A, request one of customer B's resources by ID (/api/.../<id> or the GraphQL field). The response must be 404, 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:

  1. Run the application in production mode (NODE_ENV=production, production build) — development mode intentionally shows more.
  2. 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.
  3. 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.
  4. Static support check: confirm the project exports error boundaries —
    grep -rn "ErrorBoundary" app/routes app/root.tsx
    A missing RootErrorBoundary means 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:

  1. Find modules referencing secrets outside .server files:
    grep -rln "process.env" app extensions --include="*.ts" --include="*.tsx" | grep -v ".server."
    Review each hit: references to non-FRONT_COMMERCE_WEB_* variables in a non-.server file are violations (see AUDIT-INFRA-04 for the environment variable rule itself).
  2. Search for credential-shaped code outside .server files:
    grep -rln "apiKey\|clientSecret\|Authorization.*Bearer\|privateKey" app extensions | grep -v ".server."
  3. Build the project and scan the client output for known secret values or variable names:
    grep -rl "FRONT_COMMERCE_" build/client/assets | head
    Any match containing a non-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:

  1. Header injection candidates:
    grep -rn "setHeader\|Content-Disposition" app extensions
    Any template-string interpolation of a request-derived value without a replace(/[\r\n"]/g, ...)-style sanitizer is a violation.
  2. Open-redirect candidates:
    grep -rn "redirect(" app extensions | grep -iv "\"/"
    A redirect target built from searchParams, form data, or any external value must be validated against the site's own origin or a fixed allow-list.
  3. XSS through links:
    grep -rn "href={" app extensions | grep -v "href={\`/\|href={\"/"
    Any href fed by user or CMS data must go through a scheme check that accepts only http: and https: (parse with new URL() and compare protocol).
  4. Also review dangerouslySetInnerHTML occurrences: 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:

  1. Committed environment files:
    git ls-files | grep -E "(^|/)\.env"
    git log --all --diff-filter=A --name-only -- "*.env" "*/.env"
    Only .env.dist-style templates without real values are acceptable. A real .env anywhere in history requires secret rotation, not just deletion.
  2. Hardcoded credentials:
    grep -rniE "(api[_-]?key|secret|password|token)\s*[:=]\s*[\"'][^\"']{8,}" app extensions front-commerce.config.ts
    Review each hit; a literal value that works against a real service is a violation.
  3. Secrets in logs:
    grep -rn "console.log\|logger\.\|debug(" app extensions | grep -iE "token|secret|password|authorization"
    Logging a received or expected credential — even at debug level — is a violation. Log lengths or hashes instead.

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:

  1. Find list-shaped inputs:
    grep -rn "getAll(\|\.split(\"," app/routes extensions
  2. Find fan-out patterns over request-derived arrays:
    grep -rn "Promise.all\|Promise.allSettled" app/routes extensions
  3. For each hit fed by request data, verify a length guard precedes the fan-out (for example if (skus.length > 20) throw new Response(..., { status: 400 })). A loop over unbounded user input is a violation.
  4. 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:

  1. Skip this rule if the project defines no admin surface:
    grep -rln "api.admin\|Query.admin\|isAllowedTo(\"admin" app extensions || echo "no admin surface"
  2. Find admin-looking endpoints outside the convention: search route filenames and GraphQL typedefs for admin, preview, contribution, or back-office wording that is not under api.admin.* / Query.admin. Each one bypasses the automatic guard and is a violation.
  3. For every file under routes/api.admin.*, confirm it references isAllowedTo( at least once:
    for f in $(find app extensions -name "api.admin.*"); do grep -L "isAllowedTo(" "$f"; done
    Files printed by this loop are violations.
  4. Confirm the admin permissions are registered in onServerServicesInit with serverOnly: true, and that no legacy user.journey.isAuthorizedTo(...) calls remain.