refactor: rely on PocketBase rate limiting
This commit is contained in:
1 parent
1ce69d78f0
commit
93c95e1846
5 files changed
+3
-118
No files matched your search
@@ -22,21 +22,6 @@ const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
const workshopRateLimits = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
];
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
@@ -49,7 +34,6 @@ migrate(
|
||||
users = new Collection({ type: "auth", name: "users" });
|
||||
}
|
||||
|
||||
const settings = app.settings();
|
||||
const stateCollection = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_migration_state",
|
||||
@@ -75,7 +59,6 @@ migrate(
|
||||
}
|
||||
: null,
|
||||
addedUserFields: [],
|
||||
rateLimits: cloneJson(settings.rateLimits),
|
||||
};
|
||||
const addUserField = (field) => {
|
||||
if (users.fields.getByName(field.name)) return;
|
||||
@@ -280,15 +263,6 @@ migrate(
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label),
|
||||
),
|
||||
...workshopRateLimits,
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
let stateCollection;
|
||||
@@ -306,11 +280,6 @@ migrate(
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = state.rateLimits.enabled;
|
||||
settings.rateLimits.rules = state.rateLimits.rules;
|
||||
app.save(settings);
|
||||
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
if (state.usersExisted) {
|
||||
users.passwordAuth = state.users.passwordAuth;
|
||||
|
||||
@@ -89,7 +89,6 @@ for (const required of [
|
||||
"cloneRule(users.listRule)",
|
||||
"CREATE TRIGGER submissions_revision_cas",
|
||||
"NEW.expectedRevision != OLD.revision",
|
||||
"state.rateLimits.enabled",
|
||||
"users.passwordAuth = state.users.passwordAuth",
|
||||
"users.oauth2 = state.users.oauth2",
|
||||
"app.delete(users)",
|
||||
@@ -97,13 +96,6 @@ for (const required of [
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
for (const operation of ["create", "update", "delete"]) {
|
||||
for (const collection of ["submissions", "votes", "comments", "reports"]) {
|
||||
const label = `${collection}:${operation}`;
|
||||
if (!migration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!migration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
|
||||
for (const protectedField of [
|
||||
"email:changed",
|
||||
"displayName:changed",
|
||||
|
||||
@@ -58,13 +58,11 @@ const original = {
|
||||
updateRule: "original-update",
|
||||
deleteRule: "original-delete",
|
||||
},
|
||||
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
|
||||
};
|
||||
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
|
||||
users.passwordAuth = structuredClone(original.passwordAuth);
|
||||
users.oauth2 = structuredClone(original.oauth2);
|
||||
Object.assign(users, original.rules);
|
||||
const settings = { rateLimits: structuredClone(original.rateLimits) };
|
||||
const collections = new Map([[users.id, users]]);
|
||||
const records = new Map();
|
||||
const rawQueries = [];
|
||||
@@ -99,9 +97,6 @@ const app = {
|
||||
collections.delete(collection.id);
|
||||
records.delete(collection.id);
|
||||
},
|
||||
settings() {
|
||||
return settings;
|
||||
},
|
||||
};
|
||||
|
||||
let migration;
|
||||
@@ -122,7 +117,7 @@ vm.runInNewContext(source, sandbox);
|
||||
if (!migration) throw new Error("Workshop migration did not register");
|
||||
|
||||
migration.up(app);
|
||||
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
|
||||
if (!collections.has("workshop_migration_state")) {
|
||||
throw new Error("Workshop migration did not capture state before applying changes");
|
||||
}
|
||||
if (
|
||||
@@ -145,8 +140,6 @@ for (const [name, value] of Object.entries(original.rules)) {
|
||||
}
|
||||
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
|
||||
throw new Error("Workshop user fields were not removed on rollback");
|
||||
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
|
||||
throw new Error("Rate limits were not restored");
|
||||
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
|
||||
}
|
||||
@@ -154,7 +147,6 @@ for (const name of ["workshop_migration_state", "workshop_listings", "reports",
|
||||
collections.clear();
|
||||
records.clear();
|
||||
rawQueries.length = 0;
|
||||
settings.rateLimits = structuredClone(original.rateLimits);
|
||||
|
||||
migration.up(app);
|
||||
if (!collections.has("users")) throw new Error("Fresh migration did not create the users collection");
|
||||
@@ -170,8 +162,5 @@ for (const name of [
|
||||
]) {
|
||||
if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`);
|
||||
}
|
||||
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) {
|
||||
throw new Error("Fresh rollback did not restore rate limits");
|
||||
}
|
||||
|
||||
console.log("Workshop migration up/down restoration passed");
|
||||
@@ -19,7 +19,6 @@ const baselineUser = {
|
||||
password: "WorkshopRollbackUser123!",
|
||||
note: "retained across the Workshop migration chain",
|
||||
};
|
||||
const baselineRateLimitLabel = "rollback_sentinel:create";
|
||||
const workshopCollections = [
|
||||
"workshop_migration_state",
|
||||
"submissions",
|
||||
@@ -51,7 +50,6 @@ const collectionList = async () => {
|
||||
return new Map(result.items.map((collection) => [collection.name, collection]));
|
||||
};
|
||||
const collection = (name) => request(`/api/collections/${name}`, { headers: rootHeaders });
|
||||
const settings = () => request("/api/settings", { headers: rootHeaders });
|
||||
const records = (name) =>
|
||||
request(`/api/collections/${name}/records?perPage=200`, {
|
||||
headers: rootHeaders,
|
||||
@@ -76,18 +74,6 @@ const userConfig = (users) => ({
|
||||
});
|
||||
const readSnapshot = async () => JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
|
||||
const assertManagedRateLimits = (currentSettings) => {
|
||||
assert.equal(currentSettings.rateLimits?.enabled, true, "Workshop migration did not enable rate limits");
|
||||
const rateLimitLabels = new Set(currentSettings.rateLimits?.rules?.map((rule) => rule.label));
|
||||
for (const operation of ["create", "update", "delete"]) {
|
||||
for (const name of ["submissions", "votes", "comments", "reports"]) {
|
||||
assert.ok(rateLimitLabels.has(`${name}:${operation}`), `Missing migrated rate limit ${name}:${operation}`);
|
||||
}
|
||||
}
|
||||
assert.ok(rateLimitLabels.has("users:update"), "Missing migrated users:update rate limit");
|
||||
return rateLimitLabels;
|
||||
};
|
||||
|
||||
const assertBaselineData = async () => {
|
||||
const userRecords = await records("users");
|
||||
const retainedUser = userRecords.items.find((record) => record.email === baselineUser.email);
|
||||
@@ -124,10 +110,6 @@ const assertMigrated = async () => {
|
||||
"OAuth identity update rule is not hardened",
|
||||
);
|
||||
|
||||
const currentSettings = await settings();
|
||||
const rateLimitLabels = assertManagedRateLimits(currentSettings);
|
||||
assert.ok(rateLimitLabels.has(baselineRateLimitLabel), "Workshop migration removed an unrelated rate-limit rule");
|
||||
|
||||
const stateRecords = await records("workshop_migration_state");
|
||||
assert.equal(stateRecords.items.length, 1, "Workshop migration state snapshot is missing");
|
||||
const migrationState = JSON.parse(stateRecords.items[0].snapshot);
|
||||
@@ -150,7 +132,6 @@ const assertMigrated = async () => {
|
||||
"rollback-baseline-secret",
|
||||
"Workshop migration state did not retain the OAuth secret required for rollback",
|
||||
);
|
||||
assert.deepEqual(migrationState.rateLimits, before.rateLimits);
|
||||
await assertBaselineData();
|
||||
};
|
||||
|
||||
@@ -163,10 +144,7 @@ if (phase === "fresh-prepare") {
|
||||
for (const name of [...workshopCollections, "users", "rollback_sentinel"]) {
|
||||
assert.ok(!collections.has(name), `Fresh database unexpectedly contains collection ${name}`);
|
||||
}
|
||||
const currentSettings = await settings();
|
||||
await writeFile(snapshotPath, JSON.stringify({ freshRateLimits: normalized(currentSettings.rateLimits) }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(snapshotPath, "{}", { mode: 0o600 });
|
||||
console.log("Workshop fresh rollback baseline captured");
|
||||
} else if (phase === "fresh-migrated") {
|
||||
const collections = await collectionList();
|
||||
@@ -180,7 +158,6 @@ if (phase === "fresh-prepare") {
|
||||
`Fresh migration is missing user field ${name}`,
|
||||
);
|
||||
}
|
||||
assertManagedRateLimits(await settings());
|
||||
const stateRecords = await records("workshop_migration_state");
|
||||
assert.equal(stateRecords.items.length, 1, "Fresh migration state snapshot is missing");
|
||||
const migrationState = JSON.parse(stateRecords.items[0].snapshot);
|
||||
@@ -188,18 +165,11 @@ if (phase === "fresh-prepare") {
|
||||
assert.equal(migrationState.users, null, "Fresh migration retained nonexistent user settings");
|
||||
console.log("Workshop fresh migration chain verified");
|
||||
} else if (phase === "fresh-restored") {
|
||||
const before = await readSnapshot();
|
||||
const collections = await collectionList();
|
||||
for (const name of [...workshopCollections, "users", "rollback_sentinel"]) {
|
||||
assert.ok(!collections.has(name), `Fresh rollback left collection ${name}`);
|
||||
}
|
||||
assert.ok(collections.has("_superusers"), "Fresh rollback removed PocketBase superuser data");
|
||||
const currentSettings = await settings();
|
||||
assert.deepEqual(
|
||||
normalized(currentSettings.rateLimits),
|
||||
before.freshRateLimits,
|
||||
"Fresh rollback did not restore the original rate-limit settings",
|
||||
);
|
||||
console.log("Workshop fresh rollback state verified");
|
||||
} else if (phase === "prepare") {
|
||||
const collections = await collectionList();
|
||||
@@ -246,16 +216,6 @@ if (phase === "fresh-prepare") {
|
||||
fields: [{ type: "text", name: "value", required: true, max: 100 }],
|
||||
}),
|
||||
});
|
||||
await request("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
rateLimits: {
|
||||
enabled: false,
|
||||
rules: [{ label: baselineRateLimitLabel, audience: "", duration: 17, maxRequests: 23 }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
@@ -275,21 +235,11 @@ if (phase === "fresh-prepare") {
|
||||
});
|
||||
|
||||
const users = await collection("users");
|
||||
const currentSettings = await settings();
|
||||
assert.equal(users.passwordAuth?.enabled, true);
|
||||
assert.equal(users.oauth2?.enabled, true);
|
||||
assert.ok(users.oauth2?.providers?.some((provider) => provider.name === "github"));
|
||||
assert.equal(currentSettings.rateLimits?.enabled, false);
|
||||
assert.deepEqual(
|
||||
currentSettings.rateLimits?.rules?.map((rule) => rule.label),
|
||||
[baselineRateLimitLabel],
|
||||
);
|
||||
await assertBaselineData();
|
||||
await writeFile(
|
||||
snapshotPath,
|
||||
JSON.stringify({ users: userConfig(users), rateLimits: normalized(currentSettings.rateLimits) }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await writeFile(snapshotPath, JSON.stringify({ users: userConfig(users) }), { mode: 0o600 });
|
||||
console.log("Workshop rollback baseline prepared");
|
||||
} else if (phase === "migrated") {
|
||||
await assertMigrated();
|
||||
@@ -306,12 +256,6 @@ if (phase === "fresh-prepare") {
|
||||
|
||||
const users = await collection("users");
|
||||
assert.deepEqual(userConfig(users), before.users, "Rollback did not restore user rules, fields, password, or OAuth");
|
||||
const currentSettings = await settings();
|
||||
assert.deepEqual(
|
||||
normalized(currentSettings.rateLimits),
|
||||
before.rateLimits,
|
||||
"Rollback did not restore the original rate-limit settings",
|
||||
);
|
||||
await assertBaselineData();
|
||||
console.log("Workshop full rollback state verified");
|
||||
}
|
||||
@@ -34,15 +34,6 @@ for (const required of ["submissions", "votes", "comments", "reports", "workshop
|
||||
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
|
||||
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
|
||||
}
|
||||
const settings = await request("/api/settings", { headers: rootHeaders });
|
||||
const rateLimitLabels = new Set(settings.rateLimits?.rules?.map((rule) => rule.label));
|
||||
for (const operation of ["create", "update", "delete"]) {
|
||||
for (const collection of ["submissions", "votes", "comments", "reports"]) {
|
||||
const label = `${collection}:${operation}`;
|
||||
if (!rateLimitLabels.has(label)) throw new Error(`Missing Workshop write rate limit: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!rateLimitLabels.has("users:update")) throw new Error("Workshop user/file updates are not rate limited");
|
||||
|
||||
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
|
||||
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
|
||||
|
||||
Reference in new issue
Block a user