added superuser ips whitelist

This commit is contained in:
Gani Georgiev
2026-05-01 17:42:55 +03:00
parent fe2d90641c
commit 21a5524fed
34 changed files with 1224 additions and 47 deletions
+14 -13
View File
@@ -1,14 +1,15 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.6-dev"
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_SUPERUSER_IPS_RESET_DOCS = "https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.6-dev"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
<!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-CsbGEmKA.js"></script>
<script type="module" crossorigin src="./assets/index-B5eik2f8.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-e0-DEGQo.css">
<link rel="stylesheet" crossorigin href="./assets/index-ZC1aK-6Z.css">
</head>
<body>
</body>
+1 -1
View File
@@ -832,7 +832,7 @@ function deleteDropdownItem(data, modalSettings) {
try {
await app.pb.collections.delete(data.originalCollection.name);
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
modalSettings?.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);
+3 -1
View File
@@ -513,7 +513,9 @@ hr {
text-decoration: none;
border-radius: var(--borderRadius);
color: var(--linkColor);
transition: color var(--animationSpeed);
transition:
color var(--animationSpeed),
opacity var(--animationSpeed);
user-select: none;
&:hover,
&:focus-visible {
-8
View File
@@ -235,12 +235,4 @@ window.app.checkApiError = function(err, showToast = true) {
app.pb.cancelAllRequests();
return app.pb.authStore.clear();
}
// forbidden
if (statusCode === 403) {
app.pb.cancelAllRequests();
if (window.location.hash != LOGIN_PATH) {
window.location.hash = LOGIN_PATH;
}
}
};
@@ -8,7 +8,7 @@ export function batchAccordion(pageData) {
t.summary(
null,
t.i({ className: "ri-archive-stack-line", ariaHidden: true }),
t.span({ className: "txt" }, "Batch API"),
t.span({ className: "txt" }, "Batch Web API"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings.batch.enabled) {
@@ -1,6 +1,7 @@
import { settingsSidebar } from "../settingsSidebar";
import { batchAccordion } from "./batchAccordion";
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
import { superuserAccordion } from "./superuserAccordion";
import { trustedProxyAccordion } from "./trustedProxyAccordion";
export function pageApplicationSettings() {
@@ -37,6 +38,53 @@ export function pageApplicationSettings() {
}
}
function hasSuperuserIPsChanged() {
return JSON.stringify(data.formSettings?.superuserIPs)
!= JSON.stringify(data.originalFormSettings?.superuserIPs);
}
async function saveWithConfirm() {
const superuserIPs = app.utils.toArray(data.formSettings?.superuserIPs);
if (!superuserIPs.length) {
return save();
}
return app.modals.confirm(
t.div(
{ className: "txt-center" },
t.h6(
null,
"The ONLY allowed superuser IPs will change to: ",
t.br(),
t.strong(null, superuserIPs.join(", ")),
),
t.p(null, "Please make sure that your IP is in the list or you'll be locked."),
t.p(
{ className: "txt-hint" },
"In case of lockout, you can reset the setting with the ",
t.a(
{
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
target: "_blank",
rel: "noopener noreferrer",
className: "link-primary txt-bold txt-sm",
},
t.code(
null,
"superuser ips",
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
),
),
" console command.",
),
),
() => save(),
null,
{ yesButton: "Yes, save changes" },
);
}
async function save() {
if (data.isSaving || !data.hasChanges) {
return;
@@ -48,8 +96,19 @@ export function pageApplicationSettings() {
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
init(settings);
const updatedSettings = await app.pb.settings.update(redacted);
// reauthenticate to ensure that the superuser has still access
if (hasSuperuserIPsChanged()) {
try {
await app.pb.collection("_superusers").authRefresh();
} catch (_) {
app.pb.authStore.clear();
}
}
init(updatedSettings);
app.toasts.success("Successfully saved application settings.");
} catch (err) {
@@ -73,6 +132,7 @@ export function pageApplicationSettings() {
}
data.originalFormSettings = {
superuserIPs: settings.superuserIPs || [],
meta: settings.meta || {},
batch: settings.batch || {},
trustedProxy: settings.trustedProxy || { headers: [] },
@@ -118,7 +178,7 @@ export function pageApplicationSettings() {
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
saveWithConfirm();
},
},
t.div(
@@ -158,9 +218,10 @@ export function pageApplicationSettings() {
),
t.div(
{ className: "col-lg-12" },
() => batchAccordion(data),
() => trustedProxyAccordion(data),
() => rateLimitAccordion(data),
() => batchAccordion(data),
() => superuserAccordion(data),
),
t.div(
{ className: "col-lg-12" },
@@ -249,7 +249,6 @@ export function rateLimitAccordion(pageData) {
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span({ className: "txt" }, "Enable"),
t.small({ className: "txt-hint" }, " (experimental)"),
),
),
),
@@ -0,0 +1,158 @@
export function superuserAccordion(pageData) {
const info = store({
isLoading: false,
realIP: "",
});
async function loadInfo() {
info.isLoading = true;
try {
const health = await app.pb.health.check({ requestKey: "loadSuperuserIPsInfo" });
info.realIP = health.data?.realIP || "";
info.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
info.isLoading = false;
}
}
}
return t.details(
{
pbEvent: "superuserAccordion",
className: "accordion superuser-accordion",
name: "settingsAccordion",
onmount: (el) => {
el._ipwatcher?.unwatch();
el._ipwatcher = watch(
() => JSON.stringify(app.store.settings?.trustedProxy?.headers),
(newHash, oldHash) => {
if (newHash != oldHash) {
loadInfo();
}
},
);
},
onunmount: (el) => {
el._ipwatcher?.unwatch();
},
},
t.summary(
null,
t.i({ className: "ri-fingerprint-2-line", ariaHidden: true }),
t.span({ className: "txt" }, "Superuser IPs"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings?.superuserIPs?.length) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.batch)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.div(
{ className: "content m-b-sm" },
t.p(null, "A comma separated list of superusers allowed IPs and subnets."),
t.p(
null,
"Enabling this option greatly helps hardening the security of your application because even if someone manage to get their hands on a superuser auth token they will not be able to use it.",
),
t.p(
null,
"In case your IP changes, you can always reset the field value with the ",
t.a(
{
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
target: "_blank",
rel: "noopener noreferrer",
className: "link-primary txt-bold txt-sm",
},
t.code(
null,
"superuser ips",
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
),
),
" console command.",
),
),
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "superuserIPs" },
t.span({ className: "txt" }, "Superuser IPs and subnets"),
),
t.input({
id: "superuserIPs",
name: "superuserIPs",
type: "text",
placeholder: "Leave empty for no restriction",
value: () => app.utils.joinNonEmpty(pageData.formSettings.superuserIPs),
oninput: (e) => {
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
const newStr = app.utils.joinNonEmpty(newValue);
const oldStr = app.utils.joinNonEmpty(pageData.formSettings.superuserIPs);
// has an actual change
if (oldStr != newStr) {
pageData.formSettings.superuserIPs = newValue;
}
},
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
className: () =>
`btn sm secondary transparent ${
app.utils.isEmpty(pageData.formSettings.superuserIPs) ? "hidden" : ""
}`,
onclick: () => {
pageData.formSettings.superuserIPs = [];
if (app.store.errors?.superuserIPs) {
delete app.store.errors.superuserIPs;
}
},
},
t.span({ className: "txt" }, "Clear"),
),
),
),
t.div(
{ className: "field-help" },
"Comma separated list of IPs and subnets such as: ",
t.div(
{ className: "inline-flex gap-5" },
t.div({
role: "button",
className: "label sm link-primary",
onclick: () => {
if (info.isLoading) {
return;
}
const ips = app.utils.toArray(pageData.formSettings.superuserIPs);
app.utils.pushUnique(ips, info.realIP);
pageData.formSettings.superuserIPs = ips;
},
textContent: () => info.isLoading ? "..." : (info.realIP + " (you)"),
}),
),
),
);
}
+14 -1
View File
@@ -375,7 +375,20 @@ watch(
removeErrorState(input, container);
const errMsg = app.utils.getByPath(errs, name)?.message;
const errData = app.utils.getByPath(errs, name);
let errMsg = errData?.message || "";
// merge one level nested errors into a single message
if (!errMsg && !app.utils.isEmpty(errData)) {
const combinedErrs = [];
for (let key in errData) {
if (errData[key]?.message) {
combinedErrs.push(`${key}: ${errData[key]?.message}`);
}
}
errMsg = combinedErrs.join("\n");
}
if (!errMsg) {
continue;
}