Skip to content

Exposing functions to independently apply actions pre-request and post-response #94

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { expect, test, vi, Mock } from "vitest";

import {
applyActions,
fetchActions,
callCreateAssessment,
callListFirewallPolicies,
createPartialEventWithSiteInfo,
Expand All @@ -39,6 +40,8 @@ import {
EdgeResponseInit,
FirewallPolicy,
Event,
applyPreRequestActions,
applyPostResponseActions,
} from "./index";

import { FetchApiRequest, FetchApiResponse } from "./fetchApi";
Expand Down Expand Up @@ -83,7 +86,8 @@ class TestContext extends RecaptchaContext {
log_messages: Array<[LogLevel, string[]]> = [];

constructor(config: RecaptchaConfig) {
super(config);
// Deep copy the config so it can be modified independently.
super(JSON.parse(JSON.stringify(config)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the config should already be a JS object? why is this necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config needed to be deep-copied. It's global and there are tests that modify it so I had failures when making new tests. We can also have a function that creates a new literal. Do you have a preference?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wild that there is no good way to do a deep copy.

I've been using the spread operator {...config} for stuff like this. though in cases where the object is nested (not here) it doesn't work.

Object.assign({}, config) would also work here (though (not relevant) strips methods and prototypes)

or consider just adding a comment so the motivation for the serialization is clear

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a comment for now. We can always revisit.

}

createRequest(url: string, options: any): EdgeRequest {
Expand Down Expand Up @@ -344,6 +348,7 @@ test("ApplyActions-setHeader", async () => {
expect(resp.text()).toEqual("<HTML>Hello World</HTML>");
expect(fetch).toHaveBeenCalledTimes(1);
});

test("ApplyActions-redirect", async () => {
const context = new TestContext(testConfig);
const req = new FetchApiRequest("https://www.example.com/originalreq");
Expand Down Expand Up @@ -1087,6 +1092,7 @@ test("processRequest-dump", async () => {
["debug", ["[rpc] listFirewallPolicies (ok)"]],
["debug", ["local assessment succeeded"]],
["debug", ["terminalAction: allow"]],
["debug", ["Applying response actions, ignoring request action"]],
],
exceptions: [],
create_assessment_headers: [],
Expand Down Expand Up @@ -1369,3 +1375,123 @@ test("DebugTrace-format", () => {
"list_firewall_policies_status=ok;policy_count=10;site_key_used=session;site_keys_present=asce;empty_config=apikey,endpoint;performance_counters=",
);
});

test("fetchActions-localAssessment", async () => {
const context = new TestContext(testConfig);
context.config.sessionJsInjectPath = "/teste2e;/another/path";
const req = new FetchApiRequest("https://www.example.com/teste2e");
const testPolicies = [
{
name: "test-policy",
description: "test-description",
path: "/teste2e",
actions: [{ block: {} }],
},
{
name: "test-policy2",
description: "test-description2",
path: "/teste2e",
actions: [{ redirect: {} }],
},
];
vi.stubGlobal("fetch", vi.fn());
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ firewallPolicies: testPolicies }),
}),
);
const actions = await fetchActions(context, req);
expect(actions).toEqual([
{
injectjs: {},
},
{
block: {},
},
]);
expect(fetch).toHaveBeenCalledTimes(1);
});

test("fetchActions-createAssessment", async () => {
const context = new TestContext(testConfig);
const req = new FetchApiRequest("https://www.example.com/testlocal");
vi.stubGlobal("fetch", vi.fn());
(fetch as Mock)
.mockImplementationOnce((req) => {
expect(req.url).toEqual("https://recaptchaenterprise.googleapis.com/v1/projects/12345/fetchFirewallPolicies");
const testPolicies = [
{
name: "test-policy",
description: "test-description",
path: "/testlocal",
condition: "test-condition",
actions: [{ block: {} }],
},
];
Promise.resolve({
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ firewallPolicies: testPolicies }),
});
})
.mockImplementationOnce((req) => {
expect(req.url).toEqual("https://recaptchaenterprise.googleapis.com/v1/projects/12345/assessments?key=abc123");
return Promise.resolve({
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
name: "projects/12345/assessments/1234567890",
firewallPolicyAssessment: {
firewallPolicy: {
actions: [{ block: {} }],
},
},
}),
});
});
const actions = await fetchActions(context, req);
expect(fetch).toHaveBeenCalledTimes(2);
expect(actions).toEqual([
{
block: {},
},
]);
});

test("applyPreRequestActions - terminal", async () => {
const context = new TestContext(testConfig);
const req = new FetchApiRequest("https://www.example.com/teste2e");
const resp = (await applyPreRequestActions(context, req, [{ block: {} }])) as EdgeResponse;
expect(resp.status).toEqual(403);
});

test("applyPreRequestActions - non-terminal", async () => {
const context = new TestContext(testConfig);
const req = new FetchApiRequest("https://www.example.com/teste2e");
expect(req.getHeader("my-custom-header")).toBeNull();
const resp = await applyPreRequestActions(context, req, [
{
setHeader: {
key: "my-custom-header",
value: "test123",
},
},
]);
expect(resp).toBeNull();
expect(req.getHeader("my-custom-header")).toEqual("test123");
});

test("applyPostResponseActions", async () => {
const context = new TestContext(testConfig);
const inputResp: Promise<EdgeResponse> = Promise.resolve({
status: 200,
headers: new Headers(),
text: () => "<HTML>Hello World</HTML>",
});

const resp = await applyPostResponseActions(context, await inputResp, [{injectjs: {}}]);
expect(await resp.text()).toEqual('<HTML><script src="test.js"/>Hello World</HTML>');
});
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export { ListFirewallPoliciesResponse, callListFirewallPolicies } from "./listFi

export {
applyActions,
applyPreRequestActions,
applyPostResponseActions,
fetchActions,
evaluatePolicyAssessment,
localPolicyAssessment,
policyConditionMatch,
Expand Down
81 changes: 58 additions & 23 deletions src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,27 +145,24 @@ export async function evaluatePolicyAssessment(context: RecaptchaContext, req: E
}

/**
* Apply actions to a request.
* Apply pre-request actions. If a terminal action is applied it will generate a response
* which will be returned. Non terminal actions will modify the request and return null;
*/
export async function applyActions(
export async function applyPreRequestActions(
context: RecaptchaContext,
req: EdgeRequest,
actions: action.Action[],
): Promise<EdgeResponse> {
): Promise<EdgeResponse | null> {
let terminalAction: action.Action = action.createAllowAction();
const reqNonterminalActions: action.RequestNonTerminalAction[] = [];
const respNonterminalActions: action.ResponseNonTerminalAction[] = [];

// Actions are assumed to be in order of processing. Non-terminal actions must
// be processed before terminal actions, and will be ignored if erroniously
// placed after terminal actions.
filterActions: for (const action of actions) {
for (const action of actions) {
if (isTerminalAction(action)) {
terminalAction = action;
} else if (isRequestNonTerminalAction(action)) {
reqNonterminalActions.push(action);
} else if (isResponseNonTerminalAction(action)) {
respNonterminalActions.push(action);
context.log("debug", "Applying request actions, ignoring response actions");
} else {
/* v8 ignore next */
throw new Error("Unsupported action: " + action);
Expand Down Expand Up @@ -206,23 +203,38 @@ export async function applyActions(
context.log("debug", "reqNonterminal action: setHeader");
if (isSetHeaderAction(action)) {
req.addHeader(action.setHeader.key ?? "", action.setHeader.value ?? "");
continue;
}
if (isSubstituteAction(action)) {
} else if (isSubstituteAction(action)) {
context.log("debug", "reqNonterminal action: substitute");
const url = new URL(req.url);
req.url = `${url.origin}${action.substitute.path}`;
continue;
} else {
/* v8 ignore next 2 lines */
throw new Error("Unsupported pre-request action: " + action);
}
/* v8 ignore next 2 lines */
throw new Error("Unsupported pre-request action: " + action);
}

context.log("debug", "terminalAction: allow");
// Fetch from the backend, whether redirected or not.
let resp = context.fetch_origin(req);
return null;
}

/**
* Apply post response actions. Returns a (possibly modified) response.
*/
export async function applyPostResponseActions(context: RecaptchaContext, resp: EdgeResponse, actions: action.Action[]): Promise<EdgeResponse> {
const respNonterminalActions: action.ResponseNonTerminalAction[] = [];
for (const action of actions) {
if (isTerminalAction(action) || isRequestNonTerminalAction(action)) {
context.log("debug", "Applying response actions, ignoring request action");
} else if (isResponseNonTerminalAction(action)) {
respNonterminalActions.push(action);
} else {
/* v8 ignore next */
throw new Error("Unsupported action: " + action);
}
}

// Handle Post-Response actions.
let modifiedResp = resp;
const once = new Set<string>();
for (const action of respNonterminalActions) {
if (isInjectJsAction(action)) {
Expand All @@ -232,24 +244,40 @@ export async function applyActions(
once.add("injectjs");
context.log("debug", "respNonterminal action: injectjs");
context.log_performance_debug("[func] injectJS - start");
resp = context.injectRecaptchaJs(await resp);
modifiedResp = await context.injectRecaptchaJs(resp);
// If 'debug' is enabled, await the response to get reasonable performance metrics.
if(context.config.debug) {
resp = Promise.resolve(await resp);
if (context.config.debug) {
modifiedResp = await Promise.resolve(resp);
}
context.log_performance_debug("[func] injectJS - end");
} else {
throw new Error("Unsupported post-response action: " + action);
}
}
return modifiedResp;
}

return resp;
/**
* Apply actions to a request.
*/
export async function applyActions(
context: RecaptchaContext,
req: EdgeRequest,
actions: action.Action[],
): Promise<EdgeResponse> {
const response = await applyPreRequestActions(context, req, actions);
if (response !== null) {
return response;
}
let resp = await context.fetch_origin(req);
return applyPostResponseActions(context, resp, actions);
}

/**
* Process reCAPTCHA request.
*
* Fetches a list of the applicable actions, given a request.
*/
export async function processRequest(context: RecaptchaContext, req: EdgeRequest): Promise<EdgeResponse> {
export async function fetchActions(context: RecaptchaContext, req: EdgeRequest): Promise<action.Action[]> {
let actions: action.Action[] = [];
try {
const localAssessment = await localPolicyAssessment(context, req);
Expand Down Expand Up @@ -279,7 +307,14 @@ export async function processRequest(context: RecaptchaContext, req: EdgeRequest
}
}
}
return actions;
}

/**
* Process reCAPTCHA request.
*/
export async function processRequest(context: RecaptchaContext, req: EdgeRequest): Promise<EdgeResponse> {
const actions = await fetchActions(context, req);
context.log_performance_debug("[func] applyActions - start");
let resp = applyActions(context, req, actions);
context.log_performance_debug("[func] applyActions - end");
Expand Down
Loading