diff --git a/arcjet/index.ts b/arcjet/index.ts index ff60e43cd..b3c196841 100644 --- a/arcjet/index.ts +++ b/arcjet/index.ts @@ -1433,6 +1433,18 @@ export default function arcjet< localRule.validate(context, details); results[idx] = await localRule.protect(context, details); + // If a rule didn't return a rule result, we need to stub it to avoid + // crashing. This should only happen if a user writes a custom local + // rule incorrectly. + if (typeof results[idx] === "undefined") { + results[idx] = new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ERROR", + reason: new ArcjetErrorReason("rule result missing"), + }); + } + log.debug( { id: results[idx].ruleId, diff --git a/arcjet/test/index.node.test.ts b/arcjet/test/index.node.test.ts index e024aa13d..fcc5b23a6 100644 --- a/arcjet/test/index.node.test.ts +++ b/arcjet/test/index.node.test.ts @@ -2304,6 +2304,16 @@ describe("SDK", () => { ), }; } + function testRuleLocalIncorrect(): ArcjetLocalRule { + return { + mode: ArcjetMode.LIVE, + type: "TEST_RULE_LOCAL_INCORRECT", + priority: 1, + validate: jest.fn(), + // @ts-expect-error + protect: jest.fn(async () => undefined), + }; + } function testRuleRemote(): ArcjetRule { return { @@ -2728,6 +2738,48 @@ describe("SDK", () => { expect(denied.protect).toHaveBeenCalledTimes(1); }); + test("does not crash if a local rule does not return a result", async () => { + const client = { + decide: jest.fn(async () => { + return new ArcjetAllowDecision({ + ttl: 0, + reason: new ArcjetTestReason(), + results: [], + }); + }), + report: jest.fn(), + }; + + const request = { + ip: "172.100.1.1", + method: "GET", + protocol: "http", + host: "example.com", + path: "/", + headers: new Headers([["User-Agent", "curl/8.1.2"]]), + "extra-test": "extra-test-value", + }; + const rule = testRuleLocalIncorrect(); + + const aj = arcjet({ + key: "test-key", + rules: [[rule]], + client, + log, + }); + + const context = { + getBody: () => Promise.resolve(undefined), + }; + + const decision = await aj.protect(context, request); + // ALLOW because the remote rule was called and it returned ALLOW + expect(decision.conclusion).toEqual("ALLOW"); + + expect(rule.validate).toHaveBeenCalledTimes(1); + expect(rule.protect).toHaveBeenCalledTimes(1); + }); + test("returns an ERROR decision if fingerprint cannot be generated", async () => { const client = { decide: jest.fn(async () => {