fix v2 hardening review findings

This commit is contained in:
ajnart committed 2026-07-28 15:28:58 +02:00
1 parent b82a6a7abc
commit a7eae3ff81
35 files changed
+1147 -277

No files matched your search

+24 -15
View File
@@ -9,11 +9,34 @@ normalize_http_url() {
-f "${WORKSHOP_URL_VALIDATOR_PATH:-/usr/local/lib/workshop-url.awk}"
}
normalize_docs_base_url() {
value=$1
case "$value" in
/*) ;;
*)
echo "DOCS_BASE_URL must start with '/'" >&2
return 1
;;
esac
case "${value%/}/" in
*[!A-Za-z0-9._~/-]* | */./* | */../*)
echo "DOCS_BASE_URL must be a safe URL path without traversal" >&2
return 1
;;
esac
printf '/%s\n' "$(printf '%s' "$value" | sed 's#^/*##; s#/*$##')"
}
if [ "${1:-}" = "--validate-url" ]; then
normalize_http_url "${2:-URL}" "${3:-}"
exit
fi
if [ "${1:-}" = "--validate-docs-base-url" ]; then
normalize_docs_base_url "${2:-}"
exit
fi
HOMARR_WEBSITE_URL=$(normalize_http_url HOMARR_WEBSITE_URL "${HOMARR_WEBSITE_URL:-https://homarr.dev}")
WORKSHOP_API_URL=$(normalize_http_url WORKSHOP_API_URL "${WORKSHOP_API_URL:-$HOMARR_WEBSITE_URL}")
WORKSHOP_WEB_URL=$(normalize_http_url WORKSHOP_WEB_URL "${WORKSHOP_WEB_URL:-$HOMARR_WEBSITE_URL/workshop}")
@@ -32,21 +55,7 @@ if [ "$(id -u)" -eq 0 ]; then
fi
if [ -d /pb_public ]; then
docs_base_url=${DOCS_BASE_URL:-/}
case "$docs_base_url" in
/*) ;;
*)
echo "DOCS_BASE_URL must start with '/'" >&2
exit 1
;;
esac
case "$docs_base_url" in
*[!A-Za-z0-9._~/-]* | *".."*)
echo "DOCS_BASE_URL must be a safe URL path without traversal" >&2
exit 1
;;
esac
docs_base_url="/$(printf '%s' "$docs_base_url" | sed 's#^/*##; s#/*$##')"
docs_base_url=$(normalize_docs_base_url "${DOCS_BASE_URL:-/}")
runtime_directory="/pb_public${docs_base_url%/}"
mkdir -p "$runtime_directory"
cat >"$runtime_directory/workshop-runtime-config.js" <<EOF
@@ -28391,6 +28391,9 @@ function getCustomWidgetHttpUrlIssue(value) {
const rawHostname = withoutTrailingRootDot(hostAndPort.hostname);
const parsedHostname = withoutTrailingRootDot(url2.hostname);
if (!rawHostname || !hasCanonicalParsedHostname(parsedHostname)) return "invalid";
if (rawHostname.startsWith("[") && parsedHostname.startsWith("[") && !rawHostname.includes(".") && rawHostname.toLowerCase() !== parsedHostname) {
return "invalid";
}
if (looksNumeric(rawHostname) && !isStrictIpv4(rawHostname)) return "invalid";
if (isStrictIpv4(parsedHostname) && rawHostname !== parsedHostname) return "invalid";
return null;
@@ -28908,8 +28911,9 @@ function parseAuthority(authority) {
const closeIndex = hostAndPort.indexOf("]");
if (closeIndex < 0) throw new TypeError("Invalid URL");
const address = hostAndPort.slice(1, closeIndex);
if (!isValidIpv6(address)) throw new TypeError("Invalid URL");
hostname3 = `[${address.toLowerCase()}]`;
const normalizedAddress = normalizeIpv6(address);
if (normalizedAddress === null) throw new TypeError("Invalid URL");
hostname3 = `[${normalizedAddress}]`;
const suffix = hostAndPort.slice(closeIndex + 1);
if (suffix) {
if (!suffix.startsWith(":")) throw new TypeError("Invalid URL");
@@ -28961,22 +28965,60 @@ function hasForbiddenUrlCharacter2(value) {
return code <= 32 || code >= 127 && code <= 160 || code === 5760 || code >= 8192 && code <= 8207 || code >= 8232 && code <= 8239 || code >= 8287 && code <= 8303 || code === 12288 || code === 65279;
});
}
function isValidIpv6(value) {
if (value.includes("%")) return false;
function normalizeIpv6(value) {
if (value.includes("%")) return null;
const address = value;
if (!address.includes(":") || address.includes(":::") || /[^0-9A-Fa-f:.]/u.test(address)) return false;
if (address.indexOf("::") !== address.lastIndexOf("::")) return false;
const groups = address.split(":").filter(Boolean);
let groupCount = groups.length;
if (!address.includes(":") || address.includes(":::") || /[^0-9A-Fa-f:.]/u.test(address)) return null;
if (address.indexOf("::") !== address.lastIndexOf("::")) return null;
if (address.startsWith(":") && !address.startsWith("::") || address.endsWith(":") && !address.endsWith("::")) {
return null;
}
const hasCompression = address.includes("::");
const [left = "", right = ""] = hasCompression ? address.split("::") : [address, ""];
if (hasCompression && left.includes(".")) return null;
const leftGroups = parseIpv6Groups(left);
const rightGroups = parseIpv6Groups(right);
if (leftGroups === null || rightGroups === null) return null;
const explicitGroupCount = leftGroups.length + rightGroups.length;
if (!hasCompression && explicitGroupCount !== 8 || hasCompression && explicitGroupCount >= 8) return null;
const groups = hasCompression ? [...leftGroups, ...Array.from({ length: 8 - explicitGroupCount }, () => 0), ...rightGroups] : leftGroups;
let bestStart = -1;
let bestLength = 1;
for (let index = 0; index < groups.length; ) {
if (groups[index] !== 0) {
index += 1;
continue;
}
let end = index + 1;
while (end < groups.length && groups[end] === 0) end += 1;
if (end - index > bestLength) {
bestStart = index;
bestLength = end - index;
}
index = end;
}
const hexadecimalGroups = groups.map((group) => group.toString(16));
if (bestStart < 0) return hexadecimalGroups.join(":");
const before = hexadecimalGroups.slice(0, bestStart).join(":");
const after = hexadecimalGroups.slice(bestStart + bestLength).join(":");
return `${before}::${after}`;
}
function parseIpv6Groups(value) {
var _a5, _b, _c, _d;
if (!value) return [];
const groups = value.split(":");
const parsed = [];
for (const [index, group] of groups.entries()) {
if (group.includes(".")) {
if (index !== groups.length - 1 || !isStrictIpv42(group)) return false;
groupCount += 1;
} else if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) {
return false;
if (index !== groups.length - 1 || !isStrictIpv42(group)) return null;
const parts = group.split(".").map(Number);
parsed.push(((_a5 = parts[0]) != null ? _a5 : 0) << 8 | ((_b = parts[1]) != null ? _b : 0), ((_c = parts[2]) != null ? _c : 0) << 8 | ((_d = parts[3]) != null ? _d : 0));
} else {
if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) return null;
parsed.push(Number.parseInt(group, 16));
}
}
return address.includes("::") ? groupCount < 8 : groupCount === 8;
return parsed;
}
function isStrictIpv42(value) {
const parts = value.split(".");
+10
View File
@@ -57,6 +57,16 @@ onRecordAuthWithOAuth2Request((event) => {
event.next();
}, "users");
onRecordUpdateRequest((event) => {
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
const originalAvatar = event.record.original().getString("avatar");
const pendingAvatarFiles = event.record.getUnsavedFiles("avatar");
if (event.record.getString("avatar") !== originalAvatar || pendingAvatarFiles.length > 0) {
rejectRequest("Workshop avatars are managed by GitHub OAuth");
}
event.next();
}, "users");
onRecordCreateRequest((event) => {
try {
const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
@@ -15,12 +15,50 @@ const managedLabels = [
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const withoutManagedRules = (rules) => rules.filter((rule) => !managedLabels.includes(rule.label));
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const deriveLegacyWriteRateLimitState = (snapshot) => ({
enabled: true,
managedRules: managedRules([
...snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
});
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop write rate-limit migration state is unavailable");
}
};
migrate(
(app) => {
const settings = app.settings();
const state = findMigrationState(app);
state.snapshot.writeRateLimits = {
enabled: settings.rateLimits.enabled,
managedRules: cloneJson(managedRules(settings.rateLimits.rules)),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...withoutManagedRules(settings.rateLimits.rules),
@@ -42,13 +80,13 @@ migrate(
},
(app) => {
const settings = app.settings();
settings.rateLimits.rules = [
...withoutManagedRules(settings.rateLimits.rules),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const snapshot = findMigrationState(app).snapshot;
const state = snapshot.writeRateLimits || deriveLegacyWriteRateLimitState(snapshot);
if (!state || typeof state.enabled !== "boolean" || !Array.isArray(state.managedRules)) {
throw new Error("Workshop write rate-limit rollback snapshot is missing or invalid");
}
settings.rateLimits.enabled = state.enabled;
settings.rateLimits.rules = [...withoutManagedRules(settings.rateLimits.rules), ...cloneJson(state.managedRules)];
app.save(settings);
},
);
@@ -9,7 +9,6 @@ const legacyReportUpdateRule =
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatar:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
@@ -0,0 +1,61 @@
/// <reference path="../pb_data/types.d.ts" />
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop rate-limit compatibility state is unavailable");
}
};
migrate(
(app) => {
const state = findMigrationState(app);
if (state.snapshot.writeRateLimits) return;
if (!state.snapshot.rateLimits || !Array.isArray(state.snapshot.rateLimits.rules)) {
throw new Error("Workshop legacy rate-limit snapshot is missing or invalid");
}
state.snapshot.writeRateLimits = {
enabled: true,
managedRules: managedRules([
...state.snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
},
() => {
// Keep the backfill until 1784240001 is rolled back; 1784240000 removes the state collection afterward.
},
);
+1
View File
@@ -13,6 +13,7 @@ ROLLBACK_CONTAINER=""
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
node apps/workshop/tests/workshop-rate-limit-migration.mjs
node apps/workshop/tests/entrypoint-url.mjs
node apps/workshop/tests/workshop-identity.mjs
node apps/workshop/tests/workshop-oauth-hook.mjs
+23
View File
@@ -10,6 +10,11 @@ const validate = (value) =>
env: { ...process.env, WORKSHOP_URL_VALIDATOR_PATH: validator },
});
const validateDocsBaseUrl = (value) =>
spawnSync("sh", [entrypoint, "--validate-docs-base-url", value], {
encoding: "utf8",
});
for (const [value, expected] of [
["http://127.0.0.1:8090/", "http://127.0.0.1:8090"],
["https://example.com/docs/", "https://example.com/docs"],
@@ -30,6 +35,8 @@ for (const value of [
"https://example.com:65536",
"https://::1",
"https://[:::1]",
"http://[:1:2:3:4:5:6:7:8]",
"http://[1:2:3:4:5:6:7:8:]",
"https://1.2.3.4.5",
"http://09.0.0.1",
"http://099.0.0.1",
@@ -46,4 +53,20 @@ for (const value of [
if (result.status === 0) throw new Error(`Expected invalid runtime URL to be rejected: ${JSON.stringify(value)}`);
}
for (const [value, expected] of [
["/", "/"],
["/docs/", "/docs"],
["/docs..preview", "/docs..preview"],
]) {
const result = validateDocsBaseUrl(value);
if (result.status !== 0 || result.stdout.trim() !== expected) {
throw new Error(`Expected valid DOCS_BASE_URL ${value}: ${result.stderr || result.stdout}`);
}
}
for (const value of ["docs", "/../docs", "/docs/../private", "/./docs", "/docs?preview=1"]) {
const result = validateDocsBaseUrl(value);
if (result.status === 0) throw new Error(`Expected invalid DOCS_BASE_URL to be rejected: ${JSON.stringify(value)}`);
}
console.log("Workshop runtime URL validation passed");
+28 -1
View File
@@ -106,12 +106,29 @@ for (const operation of ["create", "update", "delete"]) {
}
}
if (!hardeningMigration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
for (const required of [
"writeRateLimits",
"managedRules(settings.rateLimits.rules)",
"settings.rateLimits.enabled = state.enabled",
"...cloneJson(state.managedRules)",
]) {
if (!hardeningMigration.includes(required)) {
throw new Error(`Workshop write rate-limit rollback is missing persistent state restoration: ${required}`);
}
}
const accessHardeningMigration = await read("apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js");
const rateLimitCompatibilityMigration = await read(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
for (const required of ["writeRateLimits", "legacyCreateRules", "rate-limit compatibility state is unavailable"]) {
if (!rateLimitCompatibilityMigration.includes(required)) {
throw new Error(`Workshop rate-limit compatibility migration is missing: ${required}`);
}
}
for (const protectedField of [
"email:changed",
"displayName:changed",
"avatar:changed",
"avatarUrl:changed",
"githubUsername:changed",
"githubProfileUrl:changed",
@@ -120,6 +137,16 @@ for (const protectedField of [
throw new Error(`Workshop OAuth identity field remains caller-controlled: ${protectedField}`);
}
}
if (accessHardeningMigration.includes("@request.body.avatar:changed")) {
throw new Error("Workshop access rules must not use the unsupported :changed modifier for file fields");
}
if (
!hook.includes('getUnsavedFiles("avatar")') ||
!hook.includes('original().getString("avatar")') ||
!hook.includes("Workshop avatars are managed by GitHub OAuth")
) {
throw new Error("Workshop avatar updates must be rejected by the user update request hook");
}
for (const protectedField of [
"title:changed",
"description:changed",
+59 -2
View File
@@ -6,6 +6,7 @@ const nodeRequire = createRequire(import.meta.url);
const workshopUtils = nodeRequire("../pb_hooks/workshop-utils.js");
const hookSource = await readFile("apps/workshop/pb_hooks/workshop.pb.js", "utf8");
let oauthHook;
let userUpdateHook;
const ignoreHook = () => {};
vm.runInNewContext(hookSource, {
@@ -15,7 +16,9 @@ vm.runInNewContext(hookSource, {
onRecordAfterCreateSuccess: ignoreHook,
onRecordCreateRequest: ignoreHook,
onRecordDeleteRequest: ignoreHook,
onRecordUpdateRequest: ignoreHook,
onRecordUpdateRequest: (handler, collection) => {
if (collection === "users") userUpdateHook = handler;
},
onRecordAuthWithOAuth2Request: (handler, collection) => {
if (collection !== "users") throw new Error(`OAuth hook targets unexpected collection: ${collection}`);
oauthHook = handler;
@@ -24,11 +27,17 @@ vm.runInNewContext(hookSource, {
if (specifier !== "/pb_hooks/workshop-utils.js") {
throw new Error(`OAuth hook loaded unexpected dependency: ${specifier}`);
}
return workshopUtils;
return {
...workshopUtils,
rejectRequest: (message) => {
throw new Error(message);
},
};
},
});
if (typeof oauthHook !== "function") throw new Error("Workshop OAuth request hook was not registered");
if (typeof userUpdateHook !== "function") throw new Error("Workshop user update hook was not registered");
const oauth2User = {
id: "provider-user-123",
@@ -108,4 +117,52 @@ if (ignoredNextCalls !== 1 || Object.keys(ignoredCreateData).length !== 0) {
throw new Error("Non-GitHub OAuth providers must pass through without GitHub identity synchronization");
}
let unchangedAvatarNextCalls = 0;
userUpdateHook({
next: () => {
unchangedAvatarNextCalls += 1;
},
record: {
getString: () => "provider-avatar.png",
getUnsavedFiles: () => [],
original: () => ({ getString: () => "provider-avatar.png" }),
},
});
if (unchangedAvatarNextCalls !== 1) {
throw new Error("User updates that leave the provider avatar unchanged must pass through");
}
for (const avatarChange of [
{
name: "upload",
current: "provider-avatar.png",
original: "provider-avatar.png",
unsavedFiles: [{ name: "forged-avatar.png" }],
},
{
name: "removal",
current: "",
original: "provider-avatar.png",
unsavedFiles: [],
},
]) {
let nextCalls = 0;
try {
userUpdateHook({
next: () => {
nextCalls += 1;
},
record: {
getString: () => avatarChange.current,
getUnsavedFiles: () => avatarChange.unsavedFiles,
original: () => ({ getString: () => avatarChange.original }),
},
});
throw new Error(`Workshop user avatar ${avatarChange.name} was accepted`);
} catch (error) {
if (!String(error).includes("managed by GitHub OAuth")) throw error;
}
if (nextCalls !== 0) throw new Error(`Rejected avatar ${avatarChange.name} continued the update request`);
}
console.log("Workshop OAuth hook contract passed");
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const normalized = (value) => JSON.parse(JSON.stringify(value));
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const originalManagedRules = managedLabels.map((label, index) => ({
label,
audience: index % 2 === 0 ? "@auth" : "",
duration: index + 1,
maxRequests: index + 10,
}));
const originalUnmanagedRule = {
label: "unrelated:create",
audience: "",
duration: 41,
maxRequests: 42,
};
const stateCollection = { id: "workshop_migration_state" };
let stateSnapshot = JSON.stringify({ marker: "retain-existing-migration-state" });
const stateRecord = {
getString(name) {
assert.equal(name, "snapshot");
return stateSnapshot;
},
set(name, value) {
assert.equal(name, "snapshot");
stateSnapshot = value;
},
};
const settings = {
rateLimits: {
enabled: false,
rules: [originalUnmanagedRule, ...originalManagedRules],
},
};
const app = {
findCollectionByNameOrId(name) {
if (name !== stateCollection.id) throw new Error(`Missing collection ${name}`);
return stateCollection;
},
findFirstRecordByFilter(collectionId) {
if (collectionId !== stateCollection.id) throw new Error(`Missing state record ${collectionId}`);
return stateRecord;
},
save() {},
settings() {
return settings;
},
};
const loadMigration = async (path) => {
let loaded;
const source = await readFile(path, "utf8");
vm.runInNewContext(source, {
console,
migrate: (up, down) => {
loaded = { up, down };
},
});
if (!loaded) throw new Error(`Workshop migration did not register: ${path}`);
return loaded;
};
const migration = await loadMigration("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
migration.up(app);
const savedState = JSON.parse(stateSnapshot);
assert.equal(savedState.marker, "retain-existing-migration-state");
assert.deepEqual(savedState.writeRateLimits, {
enabled: false,
managedRules: originalManagedRules,
});
assert.equal(settings.rateLimits.enabled, true);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[originalUnmanagedRule],
"Applying Workshop rate limits changed unrelated rules",
);
assert.deepEqual(
new Set(settings.rateLimits.rules.map((rule) => rule.label)),
new Set([...managedLabels, originalUnmanagedRule.label]),
"Applying Workshop rate limits did not replace every managed label",
);
const laterUnmanagedRule = {
label: "added-after-migration:update",
audience: "@auth",
duration: 51,
maxRequests: 52,
};
settings.rateLimits.rules = [
laterUnmanagedRule,
...settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label)),
];
migration.down(app);
assert.equal(settings.rateLimits.enabled, false, "Rollback did not restore the original enabled flag");
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[laterUnmanagedRule],
"Rollback changed unrelated rules added after the migration",
);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label))),
originalManagedRules,
"Rollback did not restore every original managed-label rule",
);
assert.throws(
() =>
migration.up({
...app,
findCollectionByNameOrId() {
throw new Error("missing");
},
}),
/migration state is unavailable/u,
"Migration must fail closed when its persistent rollback snapshot is unavailable",
);
const legacyOriginalRules = [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
{ label: "submissions:create", audience: "", duration: 73, maxRequests: 74 },
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
];
const legacyStateCollection = { id: "workshop_migration_state" };
let legacyStateSnapshot = JSON.stringify({
marker: "already-applied-old-0001",
rateLimits: { enabled: false, rules: legacyOriginalRules },
});
const legacyStateRecord = {
getString() {
return legacyStateSnapshot;
},
set(_name, value) {
legacyStateSnapshot = value;
},
};
const legacySettings = {
rateLimits: {
enabled: true,
rules: [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...managedLabels.map((label) => ({ label, audience: "@auth", duration: 60, maxRequests: 30 })),
],
},
};
const legacyApp = {
findCollectionByNameOrId() {
return legacyStateCollection;
},
findFirstRecordByFilter() {
return legacyStateRecord;
},
save() {},
settings() {
return legacySettings;
},
};
const compatibilityMigration = await loadMigration(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
compatibilityMigration.up(legacyApp);
compatibilityMigration.down(legacyApp);
const backfilledState = JSON.parse(legacyStateSnapshot);
assert.equal(backfilledState.marker, "already-applied-old-0001");
assert.deepEqual(backfilledState.writeRateLimits, {
enabled: true,
managedRules: [
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
],
});
migration.down(legacyApp);
assert.equal(legacySettings.rateLimits.enabled, true);
assert.deepEqual(
normalized(legacySettings.rateLimits.rules),
[
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...backfilledState.writeRateLimits.managedRules,
],
"An already-applied legacy migration could not be rolled back after the compatibility backfill",
);
console.log("Workshop write rate-limit migration restoration passed");
@@ -98,11 +98,37 @@ for (const [field, value] of [
404,
);
}
const forgedAvatar = new FormData();
forgedAvatar.set(
"avatar",
new Blob(
[
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
],
{ type: "image/png" },
),
"forged-avatar.png",
);
const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: forgedAvatar,
});
const avatarResponseBody = await avatarResponse.json().catch(() => null);
if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
throw new Error(
`Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
);
}
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
headers: authorSession.headers,
});
if (
unchangedAuthor.displayName !== "Widget Author" ||
unchangedAuthor.avatar ||
unchangedAuthor.avatarUrl ||
unchangedAuthor.githubUsername ||
unchangedAuthor.githubProfileUrl
+4 -1
View File
@@ -32,11 +32,14 @@ function valid_hostname(value, labels, count, label_index) {
return 1
}
function valid_ipv6(value, percent, address, zone, collapsed, parts, count, groups, part_index) {
function valid_ipv6(value, percent, address, starts_single_colon, ends_single_colon, collapsed, parts, count, groups, part_index) {
percent = index(value, "%")
if (percent) return 0
address = value
if (address !~ /:/ || address ~ /:::/ || address !~ /^[0-9A-Fa-f:.]+$/) return 0
starts_single_colon = substr(address, 1, 1) == ":" && substr(address, 1, 2) != "::"
ends_single_colon = substr(address, length(address), 1) == ":" && substr(address, length(address) - 1) != "::"
if (starts_single_colon || ends_single_colon) return 0
collapsed = address
sub(/::/, "", collapsed)
if (collapsed ~ /::/) return 0