Merge pull request #529 from mchangrh/sanitizeVideoID

Sanitize videoID and minimum UserID length
This commit is contained in:
Ajay Ramachandran 2022-12-27 17:10:27 -05:00 committed by GitHub
commit dc0bde0e36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 195 additions and 10 deletions

View file

@ -15,6 +15,8 @@ jobs:
# Initialization
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Run Tests
timeout-minutes: 5

View file

@ -15,6 +15,8 @@ jobs:
# Initialization
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Run Tests
timeout-minutes: 5

View file

@ -16,6 +16,8 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Set config
run: |

View file

@ -21,6 +21,8 @@ jobs:
- name: Check running containers
run: docker ps
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Run Tests
env:

View file

@ -76,5 +76,6 @@
"clientSecret": "testClientSecret",
"redirectUri": "http://127.0.0.1/fake/callback"
},
"minReputationToSubmitFiller": -1
"minReputationToSubmitFiller": -1,
"minUserIDLength": 0
}

View file

@ -66,5 +66,6 @@
{
"name": "vipUsers"
}]
}
},
"minUserIDLength": 30 // minimum length of UserID to be accepted
}

View file

@ -166,7 +166,8 @@ addDefaults(config, {
},
gumroad: {
productPermalinks: ["sponsorblock"]
}
},
minUserIDLength: 30
});
loadFromEnv(config);
migrate(config);

View file

@ -22,6 +22,7 @@ import axios from "axios";
import { vote } from "./voteOnSponsorTime";
import { canSubmit } from "../utils/permissions";
import { getVideoDetails, videoDetails } from "../utils/getVideoDetails";
import * as youtubeID from "../utils/youtubeID";
type CheckResult = {
pass: boolean,
@ -185,15 +186,23 @@ async function checkUserActiveWarning(userID: string): Promise<CheckResult> {
}
async function checkInvalidFields(videoID: VideoID, userID: UserID, hashedUserID: HashedUserID
, segments: IncomingSegment[], videoDurationParam: number, userAgent: string): Promise<CheckResult> {
, segments: IncomingSegment[], videoDurationParam: number, userAgent: string, service: Service): Promise<CheckResult> {
const invalidFields = [];
const errors = [];
if (typeof videoID !== "string" || videoID?.length == 0) {
invalidFields.push("videoID");
}
if (typeof userID !== "string" || userID?.length < 30) {
if (service === Service.YouTube && config.mode !== "test") {
const sanitizedVideoID = youtubeID.validate(videoID) ? videoID : youtubeID.sanitize(videoID);
if (!youtubeID.validate(sanitizedVideoID)) {
invalidFields.push("videoID");
errors.push("YouTube videoID could not be extracted");
}
}
const minLength = config.minUserIDLength;
if (typeof userID !== "string" || userID?.length < minLength) {
invalidFields.push("userID");
if (userID?.length < 30) errors.push(`userID must be at least 30 characters long`);
if (userID?.length < minLength) errors.push(`userID must be at least ${minLength} characters long`);
}
if (!Array.isArray(segments) || segments.length == 0) {
invalidFields.push("segments");
@ -484,7 +493,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
//hash the userID
const userID = await getHashCache(paramUserID || "");
const invalidCheckResult = await checkInvalidFields(videoID, paramUserID, userID, segments, videoDurationParam, userAgent);
const invalidCheckResult = await checkInvalidFields(videoID, paramUserID, userID, segments, videoDurationParam, userAgent, service);
if (!invalidCheckResult.pass) {
return res.status(invalidCheckResult.errorCode).send(invalidCheckResult.errorMessage);
}

View file

@ -325,7 +325,7 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
return { status: 400 };
}
// Ignore this vote, invalid
if (paramUserID.length < 30 && config.mode !== "test") {
if (paramUserID.length < config.minUserIDLength) {
return { status: 200 };
}

View file

@ -94,7 +94,8 @@ export interface SBSConfig {
}
gumroad: {
productPermalinks: string[],
}
},
minUserIDLength: number
}
export interface WebhookConfig {

26
src/utils/youtubeID.ts Normal file
View file

@ -0,0 +1,26 @@
import { VideoID } from "../types/segments.model";
const idRegex = new RegExp(/([0-9A-Za-z_-]{11})/); // group to always be index 1
const exclusiveIdegex = new RegExp(`^${idRegex.source}$`);
// match /c/, /channel/, /@channel, full UUIDs
const negativeRegex = new RegExp(/(\/(channel|c)\/.+)|(\/@.+)|([a-f0-9]{64,65})|(youtube\.com\/clip\/)/);
const urlRegex = new RegExp(`(?:v=|/|youtu.be/)${idRegex.source}(?:|/|[?&]t=\\d+s?)>?(?:\\s|$)`);
const negateIdRegex = new RegExp(/(?:[^0-9A-Za-z_-]*?)/);
const looseEndsRegex = new RegExp(`${negateIdRegex.source}${idRegex.source}${negateIdRegex.source}`);
export const validate = (id: string): boolean => exclusiveIdegex.test(id);
export const sanitize = (id: string): VideoID | null => {
// first decode URI
id = decodeURIComponent(id);
// strict matching
const strictMatch = id.match(exclusiveIdegex)?.[1];
const urlMatch = id.match(urlRegex)?.[1];
// return match, if not negative, return looseMatch
const looseMatch = id.match(looseEndsRegex)?.[1];
return strictMatch ? (strictMatch as VideoID)
: negativeRegex.test(id) ? null
: urlMatch ? (urlMatch as VideoID)
: looseMatch ? (looseMatch as VideoID)
: null;
};

View file

@ -64,5 +64,6 @@
"clientSecret": "testClientSecret",
"redirectUri": "http://127.0.0.1/fake/callback"
},
"minReputationToSubmitFiller": -1
"minReputationToSubmitFiller": -1,
"minUserIDLength": 0
}

12
test/cases/environment.ts Normal file
View file

@ -0,0 +1,12 @@
import assert from "assert";
import { config } from "../../src/config";
describe("environment", () => {
it("minUserIDLength should be < 10", () => {
assert(config.minUserIDLength < 10);
});
it("nodeJS major version should be >= 16", () => {
const [major] = process.versions.node.split(".").map(i => parseInt(i));
assert(major >= 16);
});
});

View file

@ -0,0 +1,125 @@
import assert from "assert";
import { client } from "../utils/httpClient";
import { config } from "../../src/config";
import sinon from "sinon";
import { sanitize } from "../../src/utils/youtubeID";
// videoID array
const badVideoIDs = [
["null", "< 11"],
["dQw4w9WgXc?", "invalid characters"],
["https://www.youtube.com/clip/UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a", "clip URL"],
["https://youtube.com/channel/UCaAa00aaaAA0a0a0AaaAAAA", "channel ID (UC)"],
["https://www.youtube.com/@LinusTechTips", "channel @username"],
["https://www.youtube.com/@GamersNexus", "channel @username"],
["https://www.youtube.com/c/LinusTechTips", "custom channel /c/"],
["https://www.youtube.com/c/GamersNexus", "custom channel /c/"],
["https://www.youtube.com/", "home/ page URL"],
["03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430","UUID"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430", "full #requiredSegments uuid"],
["","empty videoID"]
];
const goodVideoIDs = [
["dQw4w9WgXcQ", "standalone videoID"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "?watch link"],
["http://www.youtube.com/watch?v=dQw4w9WgXcQ", "http link"],
["www.youtube.com/watch?v=dQw4w9WgXcQ", "no protocol link"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ?t=2", "trailing &t parameter"],
["https://youtu.be/dQw4w9WgXcQ","youtu.be"],
["youtu.be/dQw4w9WgXcQ","no protocol youtu.be"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=00000000000","#requiredsegment link"],
["https://www.youtube.com/embed/dQw4w9WgXcQ?wmode=transparent&rel=0&autohide=1&showinfo=1&fs=1&enablejsapi=0&theme=light", "long embedded link"],
["http://m.youtube.com/watch?v=dQw4w9WgXcQ&app=m&persist_app=1", "force persist desktop"],
["http://m.youtube.com/watch?v=dQw4w9WgXcQ", "mobile"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/watch&list"],
["https://www.youtube.com/embed/dQw4w9WgXcQ?list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/embed/video"],
["dQw4w9WgXcQ\n", "escaped newline"],
["dQw4w9WgXcQ\t", "escaped tab"],
["%20dQw4w9WgXcQ%20%20%20", "urlencoded"],
["https://sb.ltn.fi/video/dQw4w9WgXcQ/","sbltnfi link"],
["https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=0m10s", "anchor as t parameter"],
];
const edgeVideoIDs = [
["https://www.youtube.com/embed/videoseries?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/videoseries"],
["https://www.youtube.com/embed/playlist?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/playlist"],
["PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "playlist ID"],
["UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a","clip ID"],
["https://www.youtube.com/GamersNexus", "channel custom URL"],
["https://www.youtube.com/LinusTechTips", "channel custom URL"],
];
const targetVideoID = "dQw4w9WgXcQ";
// tests
describe("YouTube VideoID validation - failing tests", () => {
for (const testCase of badVideoIDs) {
it(`Should error on invalid videoID - ${testCase[1]}`, () => {
assert.equal(sanitize(testCase[0]), null);
});
}
});
describe("YouTube VideoID validation - passing tests", () => {
for (const testCase of goodVideoIDs) {
it(`Should be able to sanitize good videoID - ${testCase[1]}`, () => {
assert.equal(sanitize(testCase[0]), targetVideoID);
});
}
});
describe("YouTube VideoID validation - edge cases tests", () => {
for (const testCase of edgeVideoIDs) {
it(`edge cases produce bad results - ${testCase[1]}`, () => {
assert.ok(sanitize(testCase[0]));
});
}
});
// stubs
const mode = "production";
let stub: sinon.SinonStub;
// constants
const endpoint = "/api/skipSegments";
const userID = "postVideoID_user1";
const expectedError = `No valid videoID. YouTube videoID could not be extracted`;
// helper functions
const postSkipSegments = (videoID: string) => client({
method: "POST",
url: endpoint,
params: {
videoID,
startTime: Math.random(),
endTime: 10,
userID,
service: "YouTube",
category: "sponsor"
}
});
describe("VideoID Validation - postSkipSegments", () => {
before(() => stub = sinon.stub(config, "mode").value(mode));
after(() => stub.restore());
it("Should return production mode if stub worked", (done) => {
assert.strictEqual(config.mode, mode);
done();
});
it(`Should return 400 for invalid videoID`, (done) => {
postSkipSegments("123456").then(res => {
assert.strictEqual(res.status, 400);
assert.strictEqual(res.data, expectedError);
done();
})
.catch(err => done(err));
});
it(`Should return 200 for valid videoID`, (done) => {
postSkipSegments("dQw4w9WgXcQ").then(res => {
assert.strictEqual(res.status, 200);
done();
})
.catch(err => done(err));
});
});