Add optional URL protection to createRegisterHandler (#148)
* validateAllowSaleorUrls impl * Implement in handler and add test * update codeowners * Apply suggestions from code review Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com> * Rename param Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
parent
715eb6abf0
commit
67cded2e2a
5 changed files with 142 additions and 24 deletions
|
@ -1 +1 @@
|
||||||
* @saleor/devtools
|
* @saleor/marketplace
|
|
@ -4,8 +4,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||||
import { APL } from "../../APL";
|
import { APL } from "../../APL";
|
||||||
import { createAppRegisterHandler } from "./create-app-register-handler";
|
import { createAppRegisterHandler } from "./create-app-register-handler";
|
||||||
|
|
||||||
describe("create-app-register-handler", () => {
|
|
||||||
it("Sets auth data for correct request", async () => {
|
|
||||||
vi.mock("../../get-app-id", () => ({
|
vi.mock("../../get-app-id", () => ({
|
||||||
getAppId: vi.fn().mockResolvedValue("42"),
|
getAppId: vi.fn().mockResolvedValue("42"),
|
||||||
}));
|
}));
|
||||||
|
@ -27,6 +25,8 @@ describe("create-app-register-handler", () => {
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("create-app-register-handler", () => {
|
||||||
|
it("Sets auth data for correct request", async () => {
|
||||||
const { res, req } = createMocks({
|
const { res, req } = createMocks({
|
||||||
/**
|
/**
|
||||||
* Use body, instead of params, otherwise - for some reason - param is not accessible in mock request
|
* Use body, instead of params, otherwise - for some reason - param is not accessible in mock request
|
||||||
|
@ -61,4 +61,33 @@ describe("create-app-register-handler", () => {
|
||||||
jwks: "{}",
|
jwks: "{}",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Returns 403 if configured to work only for specific saleor URL and try to install on prohibited one", async () => {
|
||||||
|
const { res, req } = createMocks({
|
||||||
|
/**
|
||||||
|
* Use body, instead of params, otherwise - for some reason - param is not accessible in mock request
|
||||||
|
* Maybe this is a bug https://github.com/howardabrams/node-mocks-http/blob/master/lib/mockRequest.js
|
||||||
|
*/
|
||||||
|
body: {
|
||||||
|
auth_token: "mock-auth-token",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
host: "some-saleor-host.cloud",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"saleor-api-url": "https://wrong-saleor-domain.saleor.cloud/graphql/",
|
||||||
|
"saleor-domain": "https://wrong-saleor-domain.saleor.cloud/",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = createAppRegisterHandler({
|
||||||
|
apl: mockApl,
|
||||||
|
allowedSaleorUrls: [(url: string) => url === "https://mock-saleor-domain.saleor.cloud"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(403);
|
||||||
|
expect(res._getData().success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,23 +9,48 @@ import { fetchRemoteJwks } from "../../fetch-remote-jwks";
|
||||||
import { getAppId } from "../../get-app-id";
|
import { getAppId } from "../../get-app-id";
|
||||||
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
|
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
|
||||||
import { HasAPL } from "../../saleor-app";
|
import { HasAPL } from "../../saleor-app";
|
||||||
|
import { validateAllowSaleorUrls } from "./validate-allow-saleor-urls";
|
||||||
|
|
||||||
const debug = createDebug("createAppRegisterHandler");
|
const debug = createDebug("createAppRegisterHandler");
|
||||||
|
|
||||||
export type CreateAppRegisterHandlerOptions = HasAPL;
|
export type CreateAppRegisterHandlerOptions = HasAPL & {
|
||||||
|
/**
|
||||||
|
* Protect app from being registered in Saleor other than specific.
|
||||||
|
* By default, allow everything.
|
||||||
|
*
|
||||||
|
* Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/)
|
||||||
|
* or a function that receives a full Saleor API URL ad returns true/false.
|
||||||
|
*/
|
||||||
|
allowedSaleorUrls?: Array<string | ((saleorApiUrl: string) => boolean)>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates API handler for Next.js. Creates handler called by Saleor that registers app.
|
* Creates API handler for Next.js. Creates handler called by Saleor that registers app.
|
||||||
* Hides implementation details if possible
|
* Hides implementation details if possible
|
||||||
* In the future this will be extracted to separate sdk/next package
|
* In the future this will be extracted to separate sdk/next package
|
||||||
*/
|
*/
|
||||||
export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOptions) => {
|
export const createAppRegisterHandler = ({
|
||||||
|
apl,
|
||||||
|
allowedSaleorUrls,
|
||||||
|
}: CreateAppRegisterHandlerOptions) => {
|
||||||
const baseHandler: Handler = async (request) => {
|
const baseHandler: Handler = async (request) => {
|
||||||
debug("Request received");
|
debug("Request received");
|
||||||
const authToken = request.params.auth_token;
|
const authToken = request.params.auth_token;
|
||||||
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string;
|
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string;
|
||||||
const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER] as string;
|
const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER] as string;
|
||||||
|
|
||||||
|
if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) {
|
||||||
|
debug("Validation of URL %s against allowSaleorUrls param resolves to false, throwing");
|
||||||
|
|
||||||
|
return Response.Forbidden({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "SALEOR_URL_PROHIBITED",
|
||||||
|
message: "This app expects to be installed only in allowed saleor instances",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { configured: aplConfigured } = await apl.isConfigured();
|
const { configured: aplConfigured } = await apl.isConfigured();
|
||||||
|
|
||||||
if (!aplConfigured) {
|
if (!aplConfigured) {
|
||||||
|
|
42
src/handlers/next/validate-allow-saleor-urls.test.ts
Normal file
42
src/handlers/next/validate-allow-saleor-urls.test.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { validateAllowSaleorUrls } from "./validate-allow-saleor-urls";
|
||||||
|
|
||||||
|
const saleorCloudUrlMock = "https://my-shop.saleor.cloud/graphql/";
|
||||||
|
const onPremiseSaleorUrlMock = "https://my-shop-123.aws-services.com/graphql/";
|
||||||
|
|
||||||
|
const saleorCloudRegexValidator = (url: string) => /https:\/\/.*.saleor.cloud\/graphql\//.test(url);
|
||||||
|
|
||||||
|
describe("validateAllowSaleorUrls", () => {
|
||||||
|
it("Passes any URL if allow list is empty", () => {
|
||||||
|
expect(validateAllowSaleorUrls(saleorCloudUrlMock, [])).toBe(true);
|
||||||
|
expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Passes only for URL that was exactly matched in provided allow list array", () => {
|
||||||
|
expect(validateAllowSaleorUrls(saleorCloudUrlMock, [saleorCloudUrlMock])).toBe(true);
|
||||||
|
expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [saleorCloudUrlMock])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Validates against custom function provided to allow list", () => {
|
||||||
|
expect(validateAllowSaleorUrls(saleorCloudUrlMock, [saleorCloudRegexValidator])).toBe(true);
|
||||||
|
expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [saleorCloudRegexValidator])).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Validates against more than one argument in allow list", () => {
|
||||||
|
expect(
|
||||||
|
validateAllowSaleorUrls(saleorCloudUrlMock, [
|
||||||
|
saleorCloudRegexValidator,
|
||||||
|
onPremiseSaleorUrlMock,
|
||||||
|
])
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
validateAllowSaleorUrls(onPremiseSaleorUrlMock, [
|
||||||
|
saleorCloudRegexValidator,
|
||||||
|
onPremiseSaleorUrlMock,
|
||||||
|
])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
22
src/handlers/next/validate-allow-saleor-urls.ts
Normal file
22
src/handlers/next/validate-allow-saleor-urls.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { CreateAppRegisterHandlerOptions } from "./create-app-register-handler";
|
||||||
|
|
||||||
|
export const validateAllowSaleorUrls = (
|
||||||
|
saleorApiUrl: string,
|
||||||
|
allowedUrls: CreateAppRegisterHandlerOptions["allowedSaleorUrls"]
|
||||||
|
) => {
|
||||||
|
if (!allowedUrls || allowedUrls.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const urlOrFn of allowedUrls) {
|
||||||
|
if (typeof urlOrFn === "string" && urlOrFn === saleorApiUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof urlOrFn === "function" && urlOrFn(saleorApiUrl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
Loading…
Reference in a new issue