From 1ec4faaac5902b9cf012bf09534a0c3a96ca60e7 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 18:01:14 +0200 Subject: [PATCH] security: restrict installs to ALLOWED_SHOP and remove generic landing form --- app/routes/_index/route.tsx | 55 ++++++++++++--------------------- app/routes/auth.login/route.tsx | 37 ++++++++++++++++++++-- deploy/.env.dev.example | 3 ++ deploy/.env.prod.example | 3 ++ 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx index 3ed06fa..ece2ab9 100644 --- a/app/routes/_index/route.tsx +++ b/app/routes/_index/route.tsx @@ -1,56 +1,41 @@ import type { LoaderFunctionArgs } from "react-router"; -import { redirect, Form, useLoaderData } from "react-router"; - -import { login } from "../../shopify.server"; +import { redirect, useLoaderData } from "react-router"; import styles from "./styles.module.css"; export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); + const allowedShop = process.env.ALLOWED_SHOP?.trim(); + const shop = url.searchParams.get("shop"); - if (url.searchParams.get("shop")) { - throw redirect(`/app?${url.searchParams.toString()}`); + // If a shop param is present and it's the allow-listed merchant, send them + // straight into the embedded app. Any other shop is rejected so this URL + // can't be used to install the app on arbitrary stores. + if (shop) { + if (!allowedShop || shop.toLowerCase() === allowedShop.toLowerCase()) { + throw redirect(`/app?${url.searchParams.toString()}`); + } + throw new Response("This app is private and not available for installation.", { status: 403 }); } - return { showForm: Boolean(login) }; + return { allowedShop: allowedShop ?? null }; }; export default function App() { - const { showForm } = useLoaderData(); + const { allowedShop } = useLoaderData(); return (
-

A short heading about [your app]

+

LinumIQ Invoice

- A tagline about [your app] that describes your value proposition. + Private Shopify app for issuing GoBD-compliant PDF invoices. +

+

+ {allowedShop + ? `This installation is reserved for ${allowedShop}. Open the app from the Shopify admin.` + : "Open the app from the Shopify admin."}

- {showForm && ( -
- - -
- )} -
    -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
); diff --git a/app/routes/auth.login/route.tsx b/app/routes/auth.login/route.tsx index 6ecf717..edf0f71 100644 --- a/app/routes/auth.login/route.tsx +++ b/app/routes/auth.login/route.tsx @@ -6,13 +6,46 @@ import { Form, useActionData, useLoaderData } from "react-router"; import { login } from "../../shopify.server"; import { loginErrorMessage } from "./error.server"; +function enforceAllowedShop(request: Request) { + const allowedShop = process.env.ALLOWED_SHOP?.trim(); + if (!allowedShop) return; + const url = new URL(request.url); + const fromQuery = url.searchParams.get("shop"); + let fromBody: string | null = null; + // Action requests submit the shop in the form body; we re-read it here. + // (request.formData() can only be consumed once, so we clone.) + if (request.method === "POST") { + // We can't await the clone here without making this async; instead the + // caller awaits the actual action and we re-validate the redirect target + // via the query string check above. The action wrapper below also runs + // a body check before delegating to `login()`. + } + if (fromQuery && fromQuery.toLowerCase() !== allowedShop.toLowerCase() && !fromBody) { + throw new Response("This app is private.", { status: 403 }); + } +} + +async function enforceAllowedShopFromBody(request: Request) { + const allowedShop = process.env.ALLOWED_SHOP?.trim(); + if (!allowedShop) return request; + const cloned = request.clone(); + const form = await cloned.formData(); + const shop = (form.get("shop") ?? "").toString().trim().toLowerCase(); + if (shop && shop !== allowedShop.toLowerCase()) { + throw new Response("This app is private.", { status: 403 }); + } + return request; +} + export const loader = async ({ request }: LoaderFunctionArgs) => { + enforceAllowedShop(request); const errors = loginErrorMessage(await login(request)); - return { errors }; + return { errors, allowedShop: process.env.ALLOWED_SHOP ?? null }; }; export const action = async ({ request }: ActionFunctionArgs) => { + await enforceAllowedShopFromBody(request); const errors = loginErrorMessage(await login(request)); return { @@ -23,7 +56,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { export default function Auth() { const loaderData = useLoaderData(); const actionData = useActionData(); - const [shop, setShop] = useState(""); + const [shop, setShop] = useState(loaderData.allowedShop ?? ""); const { errors } = actionData || loaderData; return ( diff --git a/deploy/.env.dev.example b/deploy/.env.dev.example index 66e0600..a9fe7f3 100644 --- a/deploy/.env.dev.example +++ b/deploy/.env.dev.example @@ -10,6 +10,9 @@ SHOPIFY_API_SECRET=REPLACE_ME # Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.dev.toml. SHOPIFY_APP_URL=https://invoice-app-dev.linumiq.com +# Single-merchant lock-in: only this myshopify domain may install the app. +ALLOWED_SHOP=linumiq-dev.myshopify.com + # Must match `scopes` in shopify.app.dev.toml. SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 9fc153d..16a6e5c 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -10,6 +10,9 @@ SHOPIFY_API_SECRET=REPLACE_ME # Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.prod.toml. SHOPIFY_APP_URL=https://invoice-app.linumiq.com +# Single-merchant lock-in: only this myshopify domain may install the app. +ALLOWED_SHOP=5aiizq-ti.myshopify.com + # Must match `scopes` in shopify.app.prod.toml. SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files