Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
+65
-2
@@ -97,6 +97,40 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
|
||||
}
|
||||
|
||||
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
|
||||
pub fn client_ip(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
||||
.unwrap_or("local")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const MAX_FAILURES: usize = 5;
|
||||
const LOCK_MS: i64 = 15 * 60 * 1000;
|
||||
|
||||
/// True if this ip or username has too many recent failures.
|
||||
fn locked(app: &App, keys: &[String]) -> bool {
|
||||
let now = crate::types::now_ms();
|
||||
let mut map = app.login_failures.lock().unwrap();
|
||||
keys.iter().any(|k| {
|
||||
let v = map.entry(k.clone()).or_default();
|
||||
v.retain(|t| now - t < LOCK_MS);
|
||||
v.len() >= MAX_FAILURES
|
||||
})
|
||||
}
|
||||
|
||||
fn record_failure(app: &App, keys: &[String]) {
|
||||
let now = crate::types::now_ms();
|
||||
let mut map = app.login_failures.lock().unwrap();
|
||||
for k in keys {
|
||||
map.entry(k.clone()).or_default().push(now);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||
let token = token_from_headers(headers)?;
|
||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
||||
@@ -104,7 +138,7 @@ pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||
|
||||
/// Paths reachable without a login.
|
||||
fn is_public(path: &str) -> bool {
|
||||
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||
.iter()
|
||||
.any(|p| path.ends_with(p))
|
||||
}
|
||||
@@ -177,14 +211,23 @@ pub struct LoginBody {
|
||||
}
|
||||
|
||||
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
|
||||
let ip = client_ip(&headers);
|
||||
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||||
if locked(&app, &keys) {
|
||||
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
|
||||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||||
}
|
||||
let found = app.store.user_by_name(body.username.trim())?;
|
||||
// Verify even for unknown users so timing does not reveal which names exist.
|
||||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||||
record_failure(&app, &keys);
|
||||
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||
};
|
||||
app.audit(Some(&user), "login", "password", &ip);
|
||||
let cookie = issue_login(&app, &headers, &user)?;
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
@@ -208,7 +251,13 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
|
||||
};
|
||||
let ip = client_ip(&headers);
|
||||
let keys = [format!("ip:{ip}")];
|
||||
if locked(&app, &keys) {
|
||||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
|
||||
}
|
||||
if body.code.trim() != expected {
|
||||
record_failure(&app, &keys);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
|
||||
}
|
||||
@@ -222,6 +271,7 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
||||
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
|
||||
app.store.adopt_orphans(&user.id)?;
|
||||
*app.setup_code.lock().unwrap() = None;
|
||||
app.audit(Some(&user), "setup", "first admin created", &ip);
|
||||
let cookie = issue_login(&app, &headers, &user)?;
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
@@ -305,6 +355,7 @@ pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
||||
}
|
||||
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
|
||||
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
|
||||
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
|
||||
Ok(Json(json!(user)))
|
||||
}
|
||||
|
||||
@@ -314,6 +365,8 @@ pub struct UpdateUserBody {
|
||||
role: Option<String>,
|
||||
disabled: Option<bool>,
|
||||
password: Option<String>,
|
||||
/// Monthly cost limit; a negative value removes it.
|
||||
monthly_quota: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
|
||||
@@ -334,6 +387,15 @@ pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
||||
None => None,
|
||||
};
|
||||
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
|
||||
if let Some(q) = body.monthly_quota {
|
||||
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
|
||||
}
|
||||
let mut changes = Vec::new();
|
||||
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
|
||||
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
|
||||
if hash.is_some() { changes.push("password".into()); }
|
||||
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
|
||||
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
|
||||
if hash.is_some() {
|
||||
app.store.delete_user_tokens(&id)?;
|
||||
}
|
||||
@@ -351,6 +413,7 @@ pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
||||
}
|
||||
// Sessions stay (statistics); they become visible to admins only.
|
||||
app.store.delete_user(&id)?;
|
||||
app.audit(Some(&me), "user.delete", &target.username, "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -443,7 +506,7 @@ pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Quer
|
||||
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
|
||||
}
|
||||
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
|
||||
Ok(user) => match issue_login(&app, &headers, &user) {
|
||||
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
|
||||
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
|
||||
Err(e) => error_page(&format!("{e:#}")),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user