Skip to content
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

Sign k6 requests with HMAC to enable WAF bypass #4908

Merged
merged 1 commit into from
Sep 20, 2024
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
3 changes: 3 additions & 0 deletions packages/js/k6/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
"author": "Openverse Contributors <[email protected]>",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@types/k6": "^0.53.1",
"core-js": "^3.37.1",
"glob": "^11.0.0",
"rollup": "^4.21.1",
"typescript": "^5.6.2"
Expand Down
4 changes: 3 additions & 1 deletion packages/js/k6/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { defineConfig } from "rollup"
import { glob } from "glob"

import typescript from "@rollup/plugin-typescript"
import { nodeResolve } from "@rollup/plugin-node-resolve"
import commonjs from "@rollup/plugin-commonjs"

function getConfig(testFile: string) {
return defineConfig({
Expand All @@ -18,7 +20,7 @@ function getConfig(testFile: string) {
preserveModules: true,
preserveModulesRoot: "src",
},
plugins: [typescript()],
plugins: [typescript(), nodeResolve(), commonjs()],
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

New rollup plugins used to support bundling npm dependencies, because k6 doesn't support them unless they are bundled in. In our case, we need it for core-js to provide a sensible URL API.

I chose this as an alternative to the jslib URL implementation because (a) that still requires importing URL and (b) there are no means to support types for it. The suggested approach for supporting types from k6 jslib libraries is to vendor the library code.

I did not try declaring the types using declare module "...jslib..." so I'll try that for good measure (which would be nice for the other jslib function we use).

However, I've found that so far the k6 and jslib implementations of certain Web APIs have very subtle and annoying differences to the specification, which mostly just causes a bit of mental overload trying to keep track of. Which is to say, it's nice to be able to bundle in polyfills from core-js that will behave according to the specification!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

k6-jslib-url just exports core-js: https://github.com/grafana/k6-jslib-url/blob/main/index.src.js

For the sake of making sure bundling in node dependencies is sorted for anyone else working in these tests, I think it's best to go ahead and use core-js directly in this PR, as a means of integrating the bundling process before it's needed otherwise.

})
}

Expand Down
15 changes: 15 additions & 0 deletions packages/js/k6/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Courtesy of @mbforbes via https://gist.github.com/robingustafsson/7dd6463d85efdddbb0e4bcd3ecc706e1?permalink_comment_id=4884925#gistcomment-4884925
Copy link
Member

Choose a reason for hiding this comment

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

Random thought but it'd be kinda cool to make folks co-authors of the PR when we reuse code like this! The comment is totally sufficient, of course.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmmm, I'd be worried about going to that extent, it might imply they were involved in the PR more broadly, because we squash commits onto main? I'd be worried about doing that without their consent.


import { createHMAC } from "k6/crypto"

import type { Algorithm } from "k6/crypto"

export function sign(
data: string | ArrayBuffer,
hashAlg: Algorithm,
secret: string | ArrayBuffer
) {
const hasher = createHMAC(hashAlg, secret)
hasher.update(data)
return hasher.digest("base64rawurl")
}
3 changes: 2 additions & 1 deletion packages/js/k6/src/frontend/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const PROJECT_ID = 3713375
export const FRONTEND_URL = __ENV.FRONTEND_URL || "https://openverse.org/"
// Default to location of `ov j p frontend prod`
export const FRONTEND_URL = __ENV.FRONTEND_URL || "http://127.0.0.1:8443/"
89 changes: 45 additions & 44 deletions packages/js/k6/src/frontend/scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,75 @@
import { group } from "k6"
import exec from "k6/execution"
import http from "k6/http"
import { check } from "k6"

import { getRandomWord, makeResponseFailedCheck } from "../utils.js"
import { getRandomWord } from "../utils.js"
import { http } from "../http.js"

import { FRONTEND_URL, PROJECT_ID } from "./constants.js"

import type { Options, Scenario } from "k6/options"

const STATIC_PAGES = ["about", "sources", "privacy", "sensitive-content"]
const TEST_LOCALES = ["en", "ru", "es", "fa"]
const TEST_PARAMS = "&license=by&extension=jpg,mp3&source=flickr,jamendo"
const TEST_PARAMS = "license=by&extension=jpg,mp3&source=flickr,jamendo"

const localePrefix = (locale: string) => {
return locale === "en" ? "" : locale + "/"
}

const visitUrl = (url: string, action: Action) => {
// eslint-disable-next-line import/no-named-as-default-member
const response = http.get(url, {
headers: { "User-Agent": "OpenverseLoadTesting" },
})
const checkResponseFailed = makeResponseFailedCheck("", url)
if (checkResponseFailed(response, action)) {
console.error(`Failed URL: ${url}`)
return 0
}
return 1
}

const parseEnvLocales = (locales: string) => {
return locales ? locales.split(",") : ["en"]
}

export function visitStaticPages() {
const locales = parseEnvLocales(__ENV.LOCALES)
console.log(
`VU: ${exec.vu.idInTest} - ITER: ${exec.vu.iterationInInstance}`
)
const ovGroup = `visit static pages for locales ${locales}`

for (const locale of locales) {
group(`visit static pages for locale ${locale}`, () => {
for (const page of STATIC_PAGES) {
visitUrl(
`${FRONTEND_URL}${localePrefix(locale)}${page}`,
"visitStaticPages"
for (const page of STATIC_PAGES) {
const url = new URL(`${localePrefix(locale)}${page}`, FRONTEND_URL)
const response = http.get(url.toString(), { tags: { ovGroup } })
const result = check(
response,
{ "status was 200": (r) => r.status === 200 },
{ ovGroup }
)

if (!result) {
console.error(
`Request failed ⨯ ${url}: ${response.status}\n${response.body}`
)
}
})
}
}
}

export function visitSearchPages() {
const locales = parseEnvLocales(__ENV.LOCALES)
const params = __ENV.PARAMS
sarayourfriend marked this conversation as resolved.
Show resolved Hide resolved
const paramsString = params ? ` with params ${params}` : ""
console.log(
`VU: ${exec.vu.idInTest} - ITER: ${exec.vu.iterationInInstance}`
)
group(`search for random word on locales ${locales}${paramsString}`, () => {
for (const MEDIA_TYPE of ["image", "audio"]) {
for (const locale of locales) {
const q = getRandomWord()
return visitUrl(
`${FRONTEND_URL}${localePrefix(locale)}search/${MEDIA_TYPE}?q=${q}${params}`,
"visitSearchPages"
const ovGroup = `search for random word on locales ${locales}`

for (const MEDIA_TYPE of ["image", "audio"]) {
for (const locale of locales) {
const url = new URL(
`${localePrefix(locale)}search/${MEDIA_TYPE}`,
FRONTEND_URL
)
const params = new URLSearchParams(__ENV.PARAMS)
params.append("q", getRandomWord())
url.search = params.toString()

const response = http.get(url.toString(), { tags: { ovGroup } })
const result = check(
response,
{ "status was 200": (r) => r.status === 200 },
{ ovGroup }
)

if (!result) {
console.error(
`Request failed ⨯ ${url}: ${response.status}\n${response.body}`
)
}
}
return undefined
})
}
}

const actions = {
Expand All @@ -95,7 +96,7 @@ const createScenario = (
}

export const SCENARIOS = {
staticPages: createScenario({ LOCALES: "en" }, "visitStaticPages"),
englishStaticPages: createScenario({ LOCALES: "en" }, "visitStaticPages"),
localeStaticPages: createScenario(
{ LOCALES: TEST_LOCALES.join(",") },
"visitStaticPages"
Expand Down Expand Up @@ -129,14 +130,14 @@ function getScenarios(

export const SCENARIO_GROUPS = {
all: getScenarios([
"staticPages",
"englishStaticPages",
"localeStaticPages",
"englishSearchPages",
"localesSearchPages",
"englishSearchPagesWithFilters",
"localesSearchPagesWithFilters",
]),
"static-en": getScenarios(["staticPages"]),
"static-en": getScenarios(["englishStaticPages"]),
"static-locales": getScenarios(["localeStaticPages"]),
"search-en": getScenarios([
"englishSearchPages",
Expand Down
83 changes: 83 additions & 0 deletions packages/js/k6/src/http.ts
Copy link
Member

Choose a reason for hiding this comment

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

This whole file is great 👨‍🍳 💋

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Wrap k6/http functions to sign requests to allow firewall bypass.
*/

import {
get,
post,
del,
options,
head,
patch,
put,
type RequestBody,
type RefinedParams,
type ResponseType,
} from "k6/http"
import { HttpURL } from "k6/experimental/tracing"

// Polyfills URL
import "core-js/web/url"
import "core-js/web/url-search-params"

import { sign } from "./crypto"

const signingSecret = __ENV.signing_secret

function getSignedParams<
RT extends ResponseType | undefined,
P extends RefinedParams<RT>,
>(url: string | HttpURL, params: P): P {
if (!signingSecret) {
return params
}

const _url = new URL(url.toString())

const timestamp = Math.floor(Date.now() / 1000)
const resource = `${_url.pathname}${_url.search}${timestamp}`
const mac = sign(resource, "sha256", signingSecret)

return {
...params,
headers: {
...(params.headers ?? {}),
"x-ov-cf-mac": mac,
"x-ov-cf-timestamp": timestamp.toString(),
},
}
}

type BodylessHttpFn = typeof get
type BodiedHttpFn = typeof post

function wrapHttpFunction<F extends BodylessHttpFn | BodiedHttpFn>(fn: F): F {
// url is always the first argument, params is always the last argument
const urlIdx = 0
const paramsIdx = fn.length - 1

return (<RT extends ResponseType | undefined>(...args: Parameters<F>) => {
const signedParams = getSignedParams(
args[urlIdx],
(args[paramsIdx] as RefinedParams<RT>) ?? {}
)

return paramsIdx === 1
? (fn as BodylessHttpFn)(args[urlIdx], signedParams)
: (fn as BodiedHttpFn)(args[urlIdx], args[1] as RequestBody, signedParams)
}) as F
}

/**
* Wrapper around k6/http that signs requests with Openverse's
* HMAC shared secret, enabling firewall bypass for load testing.
*/
export const http = {
get: wrapHttpFunction(get),
post: wrapHttpFunction(post),
del: wrapHttpFunction(del),
options: wrapHttpFunction(options),
head: wrapHttpFunction(head),
put: wrapHttpFunction(put),
patch: wrapHttpFunction(patch),
}
18 changes: 0 additions & 18 deletions packages/js/k6/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { check } from "k6"
import { type Response } from "k6/http"

// @ts-expect-error https://github.com/grafana/k6-template-typescript/issues/16
// eslint-disable-next-line import/extensions, import/no-unresolved
import { randomItem } from "https://jslib.k6.io/k6-utils/1.2.0/index.js"
Expand All @@ -13,18 +10,3 @@ const WORDS = open("/usr/share/dict/words")
.filter((w) => !w.endsWith("'s"))

export const getRandomWord = () => randomItem(WORDS)

export const makeResponseFailedCheck = (param: string, page: string) => {
return (response: Response, action: string) => {
const requestDetail = `${param ? `for param "${param} "` : ""}at page ${page} for ${action}`
if (check(response, { "status was 200": (r) => r.status === 200 })) {
console.log(`Checked status 200 ✓ ${requestDetail}.`)
return false
} else {
console.error(
`Request failed ⨯ ${requestDetail}: ${response.status}\n${response.body}`
)
return true
}
}
}
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.