diff --git a/src/routes/postBranding.ts b/src/routes/postBranding.ts index 4d7edfa..32c3fa1 100644 --- a/src/routes/postBranding.ts +++ b/src/routes/postBranding.ts @@ -59,7 +59,7 @@ export async function postBranding(req: Request, res: Response) { const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress); const isBanned = await checkBanStatus(hashedUserID, hashedIP); - if (isRequestInvalid({ + const matchedRule = isRequestInvalid({ userAgent, userAgentHeader: req.headers["user-agent"], videoDuration, @@ -72,8 +72,9 @@ export async function postBranding(req: Request, res: Response) { downvote, }, endpoint: "dearrow-postBranding", - })) { - sendNewUserWebhook(config.discordRejectedNewUserWebhookURL, hashedUserID, videoID, userAgent, req, videoDuration, title); + }); + if (matchedRule !== null) { + sendNewUserWebhook(config.discordRejectedNewUserWebhookURL, hashedUserID, videoID, userAgent, req, videoDuration, title, matchedRule); Logger.warn(`Dearrow submission rejected by request validator: ${hashedUserID} ${videoID} ${videoDuration} ${userAgent} ${req.headers["user-agent"]} ${title.title} ${thumbnail.timestamp}`); res.status(200).send("OK"); return; @@ -86,7 +87,7 @@ export async function postBranding(req: Request, res: Response) { res.status(403).send(permission.reason); return; } else if (permission.newUser) { - sendNewUserWebhook(config.discordNewUserWebhookURL, hashedUserID, videoID, userAgent, req, videoDuration, title); + sendNewUserWebhook(config.discordNewUserWebhookURL, hashedUserID, videoID, userAgent, req, videoDuration, title, undefined); } if (videoDuration && thumbnail && await checkForWrongVideoDuration(videoID, videoDuration)) { @@ -210,7 +211,7 @@ export async function postBranding(req: Request, res: Response) { } } -function sendNewUserWebhook(webhookUrl: string, hashedUserID: HashedUserID, videoID: VideoID, userAgent: any, req: Request, videoDuration: number, title: TitleSubmission) { +function sendNewUserWebhook(webhookUrl: string, hashedUserID: HashedUserID, videoID: VideoID, userAgent: any, req: Request, videoDuration: number, title: TitleSubmission, ruleName: string | undefined) { if (!webhookUrl) return; axios.post(webhookUrl, { @@ -226,6 +227,9 @@ function sendNewUserWebhook(webhookUrl: string, hashedUserID: HashedUserID, vide "thumbnail": { "url": getMaxResThumbnail(videoID), }, + "footer": { + "text": ruleName === undefined ? `Caught by permission check` : `Caught by rule '${ruleName}'`, + }, }], }) .then(res => { diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 92c88a3..cb1df51 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -510,7 +510,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise { diff --git a/src/types/config.model.ts b/src/types/config.model.ts index 8c42ca9..5a6eb21 100644 --- a/src/types/config.model.ts +++ b/src/types/config.model.ts @@ -43,6 +43,7 @@ export interface CustomPostgresReadOnlyConfig extends CustomPostgresConfig { export type ValidatorPattern = string | [string, string]; export interface RequestValidatorRule { + ruleName?: string; // mostly universal userAgent?: ValidatorPattern; userAgentHeader?: ValidatorPattern; diff --git a/src/utils/requestValidator.ts b/src/utils/requestValidator.ts index 9be58e3..ee71a5c 100644 --- a/src/utils/requestValidator.ts +++ b/src/utils/requestValidator.ts @@ -24,7 +24,8 @@ export interface RequestValidatorInput { newUsername?: string; endpoint?: string; } -export type CompiledValidityCheck = (input: RequestValidatorInput) => boolean; +export type CompiledValidityCheck = (input: RequestValidatorInput) => string | null; +type CompiledPatternCheck = (input: RequestValidatorInput) => boolean; type CompiledSegmentCheck = (input: IncomingSegment) => boolean; type InputExtractor = ( input: RequestValidatorInput, @@ -46,7 +47,7 @@ function patternToRegex(pattern: ValidatorPattern): RegExp { function compilePattern( pattern: ValidatorPattern, extractor: InputExtractor, -): CompiledValidityCheck { +): CompiledPatternCheck { const regex = patternToRegex(pattern); return (input: RequestValidatorInput) => { @@ -72,11 +73,12 @@ function compileSegmentPattern( export function compileRules( ruleDefinitions: RequestValidatorRule[], ): CompiledValidityCheck { - if (ruleDefinitions.length === 0) return () => false; + if (ruleDefinitions.length === 0) return () => null; const rules: CompiledValidityCheck[] = []; + let untitledRuleCounter = 0; for (const ruleDefinition of ruleDefinitions) { - const ruleComponents: CompiledValidityCheck[] = []; + const ruleComponents: CompiledPatternCheck[] = []; const segmentRuleComponents: CompiledSegmentCheck[] = []; for (const [ruleKey, rulePattern] of Object.entries( ruleDefinition, @@ -217,6 +219,9 @@ export function compileRules( }); break; } + case "ruleName": + // not a rule component + break; default: { // eslint-disable-next-line @typescript-eslint/no-unused-vars const _exhaustive: never = ruleKey; @@ -239,22 +244,24 @@ export function compileRules( return false; }); } + const ruleName = ruleDefinition.ruleName ?? `Untitled rule ${++untitledRuleCounter}`; rules.push((input) => { for (const rule of ruleComponents) { - if (!rule(input)) return false; + if (!rule(input)) return null; } - return true; + return ruleName; }); } return (input) => { for (const rule of rules) { - if (rule(input)) return true; + const result = rule(input); + if (result !== null) return result; } - return false; + return null; }; } -export function isRequestInvalid(input: RequestValidatorInput) { +export function isRequestInvalid(input: RequestValidatorInput): string | null { compiledRules ??= compileRules(config.requestValidatorRules); return compiledRules(input); } diff --git a/test/cases/requestValidator.ts b/test/cases/requestValidator.ts index 979c170..29aeaea 100644 --- a/test/cases/requestValidator.ts +++ b/test/cases/requestValidator.ts @@ -21,38 +21,43 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ userID: "asdfg", }), + "Untitled rule 1", ); }); it("case insensitive match", () => { - assert.ok( + assert.equal( compiledRuleset({ userID: "asDfg", }), + "Untitled rule 1", ); }); it("simple expected no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "125aaa", }), + null, ); }); it("missing field - no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); }); - describe("single case sensitive rule", () => { + describe("single case sensitive rule with name", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", // tuple patterns allow setting regex flags userID: ["^[a-z]+$", ""], }, @@ -64,39 +69,44 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ userID: "asdfg", }), + "Testing rule", ); }); it("different casing", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "asDfg", }), + null, ); }); it("extra field match", () => { - assert.ok( + assert.equal( compiledRuleset({ userID: "asdfg", userAgent: "Mozilla/5.0", }), + "Testing rule", ); }); it("simple expected no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "125aaa", }), + null, ); }); it("missing field - no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); }); @@ -104,6 +114,7 @@ describe("Request validator", () => { describe("2-pattern rule", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", userID: ["^[a-z]+$", ""], userAgent: "^Mozilla/5\\.0", }, @@ -115,48 +126,54 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ userID: "asdfg", userAgent: "Mozilla/5.0 Chromeium/213.7", }), + "Testing rule", ); }); it("only matching one pattern - fail #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "asDfg", userAgent: "Mozilla/5.0 Chromeium/213.7", }), + null, ); }); it("only matching one pattern - fail #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "asdfg", userAgent: "ReVanced/20.07.39", }), + null, ); }); it("missing one of the fields - fail #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userID: "asdfg", }), + null, ); }); it("missing one of the fields - fail #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0 Chromeium/213.7", }), + null, ); }); it("missing all fields - fail", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ videoDuration: 21.37, }), + null, ); }); }); @@ -164,6 +181,7 @@ describe("Request validator", () => { describe("1-pattern segment rule", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", description: "mini_bomba", }, ]; @@ -174,7 +192,7 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -185,10 +203,11 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("match on one of multiple segments", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -205,10 +224,11 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("match on one of multiple segments with other missing field", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -224,11 +244,12 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("no match with one segment", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -238,11 +259,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("no match with multiple segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -258,11 +280,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("one segment missing field", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -271,11 +294,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("multiple segments missing field", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -289,20 +313,23 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("zero segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [], }), + null, ); }); it("missing segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); }); @@ -310,6 +337,7 @@ describe("Request validator", () => { describe("2-pattern segment rule", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", description: "mini_bomba", startTime: "\\.\\d", }, @@ -321,7 +349,7 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -332,10 +360,11 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("match on one of multiple segments", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -352,10 +381,11 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("match on one of multiple segments with other missing field", () => { - assert.ok( + assert.equal( compiledRuleset({ segments: [ { @@ -371,11 +401,12 @@ describe("Request validator", () => { }, ], }), + "Testing rule", ); }); it("no match with one segment #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -385,11 +416,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("no match with one segment #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1.1", "2"], @@ -399,11 +431,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("no match with one segment #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -413,11 +446,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("no match with multiple segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -433,11 +467,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("no match with multiple segments with partial matches", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1.1", "2"], @@ -453,11 +488,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("one segment missing field", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -466,11 +502,12 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("multiple segments missing field", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [ { segment: ["1", "2"], @@ -484,20 +521,23 @@ describe("Request validator", () => { }, ], }), + null, ); }); it("zero segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ segments: [], }), + null, ); }); it("missing segments", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); }); @@ -505,6 +545,7 @@ describe("Request validator", () => { describe("boolean rule", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", dearrowDownvote: true, }, ]; @@ -515,28 +556,31 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ dearrow: { downvote: true, }, }), + "Testing rule", ); }); it("simple expected no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, }, }), + null, ); }); it("missing field - no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); }); @@ -544,6 +588,7 @@ describe("Request validator", () => { describe("mixed type rules", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Testing rule", titleOriginal: true, title: "mini_bomba", }, @@ -555,7 +600,7 @@ describe("Request validator", () => { }); it("simple expected match", () => { - assert.ok( + assert.equal( compiledRuleset({ dearrow: { downvote: false, @@ -565,11 +610,12 @@ describe("Request validator", () => { }, }, }), + "Testing rule", ); }); it("simple expected no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, title: { @@ -578,11 +624,12 @@ describe("Request validator", () => { }, }, }), + null, ); }); it("partial match #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, title: { @@ -591,11 +638,12 @@ describe("Request validator", () => { }, }, }), + null, ); }); it("partial match #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, title: { @@ -604,34 +652,38 @@ describe("Request validator", () => { }, }, }), + null, ); }); it("missing field - no match #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); it("missing field - no match #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, - } + }, }), + null, ); }); it("missing field - no match #3", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, thumbnail: { original: true, - } - } + }, + }, }), + null, ); }); }); @@ -639,11 +691,12 @@ describe("Request validator", () => { describe("two-rule ruleset", () => { const ruleset: RequestValidatorRule[] = [ { + ruleName: "Rule one", titleOriginal: true, }, { title: "mini_bomba", - } + }, ]; let compiledRuleset: CompiledValidityCheck; @@ -652,7 +705,7 @@ describe("Request validator", () => { }); it("matches both", () => { - assert.ok( + assert.equal( compiledRuleset({ dearrow: { downvote: false, @@ -662,23 +715,11 @@ describe("Request validator", () => { }, }, }), + "Rule one", ); }); it("matches 1", () => { - assert.ok( - compiledRuleset({ - dearrow: { - downvote: false, - title: { - title: "mini_bomba gaming", - original: false, - }, - }, - }), - ); - }); - it("matches 2", () => { - assert.ok( + assert.equal( compiledRuleset({ dearrow: { downvote: false, @@ -688,11 +729,26 @@ describe("Request validator", () => { }, }, }), + "Rule one", + ); + }); + it("matches 2", () => { + assert.equal( + compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mini_bomba gaming", + original: false, + }, + }, + }), + "Untitled rule 1", ); }); it("no match", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, title: { @@ -701,34 +757,38 @@ describe("Request validator", () => { }, }, }), + null, ); }); it("missing both fields #1", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ userAgent: "Mozilla/5.0", }), + null, ); }); it("missing both fields #2", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, - } + }, }), + null, ); }); it("missing both fields #3", () => { - assert.ok( - !compiledRuleset({ + assert.equal( + compiledRuleset({ dearrow: { downvote: false, thumbnail: { original: true, - } - } + }, + }, }), + null, ); }); });