Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
.claude

# Runtime data & certificates
data/
letsencrypt/
*.sqlite
*.sqlite-journal

14 changes: 14 additions & 0 deletions backend/internal/dead-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const internalDeadHost = {
thisData.advanced_config = "";
}

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));

// Add to audit log
Expand Down Expand Up @@ -151,6 +156,15 @@ const internalDeadHost = {

thisData = internalHost.cleanSslHstsData(thisData, row);

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof data.advanced_config !== "undefined" &&
data.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

// do the row update
await deadHostModel.query().where({ id: data.id }).patch(data);

Expand Down
32 changes: 30 additions & 2 deletions backend/internal/proxy-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ const internalProxyHost = {
thisData.advanced_config = "";
}

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
})
.then((row) => {
Expand Down Expand Up @@ -183,6 +188,15 @@ const internalProxyHost = {

thisData = internalHost.cleanSslHstsData(thisData, row);

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof thisData.advanced_config !== "undefined" &&
thisData.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

return proxyHostModel
.query()
.where({ id: thisData.id })
Expand Down Expand Up @@ -243,7 +257,14 @@ const internalProxyHost = {
.first();

if (access_data.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
const permissions = access.getPermissions ? access.getPermissions() : {};
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", access.token.getUserId(1));
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}

if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
Expand Down Expand Up @@ -430,7 +451,14 @@ const internalProxyHost = {
.orderBy(castJsonIfNeed("domain_names"), "ASC");

if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
const permissions = access.getPermissions ? access.getPermissions() : {};
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", access.token.getUserId(1));
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}

// Query is used for searching
Expand Down
18 changes: 16 additions & 2 deletions backend/internal/redirection-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ const internalRedirectionHost = {

// Fix for db field not having a default value
// for this optional field.
if (typeof data.advanced_config === "undefined") {
data.advanced_config = "";
if (typeof thisData.advanced_config === "undefined") {
thisData.advanced_config = "";
}

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
Expand Down Expand Up @@ -183,6 +188,15 @@ const internalRedirectionHost = {

thisData = internalHost.cleanSslHstsData(thisData, row);

const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof thisData.advanced_config !== "undefined" &&
thisData.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}

return redirectionHostModel
.query()
.where({ id: thisData.id })
Expand Down
9 changes: 5 additions & 4 deletions backend/internal/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const internalUser = {

let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions()));
if (auth) {
user = await authModel.query().insert({
await authModel.query().insert({
user_id: user.id,
type: auth.type,
secret: auth.secret,
Expand Down Expand Up @@ -434,7 +434,7 @@ const internalUser = {
return internalUser.get(access, { id: data.id });
})
.then((user) => {
if (user.id !== data.id) {
if (Number(user.id) !== Number(data.id)) {
// Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError(
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
Expand All @@ -444,6 +444,7 @@ const internalUser = {
return user;
})
.then((user) => {
const permData = _.omit(data, ["id"]);
// Get perms row, patch if it exists
return userPermissionModel
.query()
Expand All @@ -455,10 +456,10 @@ const internalUser = {
return userPermissionModel
.query()
.where("user_id", user.id)
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data));
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
}
// insert
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
})
.then((permissions) => {
// Add to Audit Log
Expand Down
16 changes: 14 additions & 2 deletions backend/lib/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,13 @@ export default function (tokenString) {
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);

if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", tokenUserId);
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}

const rows = await query;
Expand Down Expand Up @@ -196,14 +202,19 @@ export default function (tokenString) {
return {
token: Token,

getPermissions: () => permissions,
getRoles: () => userRoles,
hasRole: (role) => userRoles.includes(role),

/**
*
* @param {Boolean} [allowInternal]
* @returns {Promise}
*/
load: async (allowInternal) => {
if (tokenString) {
return await Token.load(tokenString);
await this.init();
return tokenData;
}
allowInternalAccess = allowInternal;
return allowInternal || null;
Expand Down Expand Up @@ -238,6 +249,7 @@ export default function (tokenString) {
permission_streams: permissions.streams,
permission_access_lists: permissions.access_lists,
permission_certificates: permissions.certificates,
permission_meta: permissions.meta,
},
};

Expand Down
94 changes: 94 additions & 0 deletions backend/lib/express/rate-limit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { isCI } from "../config.js";

/**
* In-memory sliding window rate limiter for authentication endpoints
*/

const ipRequests = new Map();

// Clean up old entries every 2 minutes
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [ip, record] of ipRequests.entries()) {
if (now - record.startTime > record.windowMs * 2) {
ipRequests.delete(ip);
}
}
}, 120000);
cleanupTimer.unref();

export default function createRateLimiter(options = {}) {
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
const max = options.max || 10; // 10 requests default
const message = options.message || "Too many attempts from this IP, please try again later.";
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;

return (req, res, next) => {
// Bypass rate limiting in CI, test environments, or when explicitly disabled
if (
isCI() ||
process.env.CI === "true" ||
process.env.NODE_ENV === "test" ||
process.env.DISABLE_RATE_LIMIT === "true"
) {
return next();
}

const clientIp =
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
req.socket?.remoteAddress ||
req.ip ||
"unknown";

const now = Date.now();
let record = ipRequests.get(clientIp);

if (!record || now - record.startTime > windowMs) {
record = {
count: 1,
startTime: now,
windowMs,
};
ipRequests.set(clientIp, record);

if (skipSuccessfulRequests) {
res.on("finish", () => {
if (res.statusCode < 400) {
const cur = ipRequests.get(clientIp);
if (cur && cur.count > 0) {
cur.count--;
}
}
});
}

return next();
}

record.count++;

if (skipSuccessfulRequests) {
res.on("finish", () => {
if (res.statusCode < 400) {
const cur = ipRequests.get(clientIp);
if (cur && cur.count > 0) {
cur.count--;
}
}
});
}

if (record.count > max) {
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
return res.status(429).json({
error: {
code: 429,
message,
},
});
}

next();
};
}

41 changes: 41 additions & 0 deletions backend/migrations/20260906220000_user_permission_meta.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { migrate as logger } from "../logger.js";

const migrateName = "user_permission_meta";

/**
* Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);

return knex.schema
.alterTable("user_permission", (table) => {
table.json("meta").nullable();
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};

/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);

return knex.schema
.alterTable("user_permission", (table) => {
table.dropColumn("meta");
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};

export { up, down };
4 changes: 4 additions & 0 deletions backend/models/user_permission.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class UserPermission extends Model {
static get tableName() {
return "user_permission";
}

static get jsonAttributes() {
return ["meta"];
}
}

export default UserPermission;
6 changes: 6 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,11 @@
"signale": {
"displayDate": true,
"displayTimestamp": true
},
"resolutions": {
"qs": "^6.16.0"
},
"overrides": {
"qs": "^6.16.0"
}
}
Loading