mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-07-21 20:43:33 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a02e80a5a |
@@ -324,6 +324,14 @@
|
|||||||
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
# IP_HEADER=X-Real-IP
|
# IP_HEADER=X-Real-IP
|
||||||
|
|
||||||
|
## Which addresses the header above is accepted from, defaults to "local".
|
||||||
|
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
|
||||||
|
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
|
||||||
|
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
|
||||||
|
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
|
||||||
|
## proxy as IPs and CIDR ranges if it connects from a public address.
|
||||||
|
# IP_HEADER_TRUSTED_PROXIES=local
|
||||||
|
|
||||||
## Icon service
|
## Icon service
|
||||||
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
@@ -461,6 +469,13 @@
|
|||||||
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
||||||
# LOGIN_RATELIMIT_MAX_BURST=10
|
# LOGIN_RATELIMIT_MAX_BURST=10
|
||||||
|
|
||||||
|
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
|
||||||
|
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
|
||||||
|
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
|
||||||
|
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
|
||||||
|
|
||||||
## BETA FEATURE: Groups
|
## BETA FEATURE: Groups
|
||||||
## Controls whether group support is enabled for organizations
|
## Controls whether group support is enabled for organizations
|
||||||
## This setting applies to organizations.
|
## This setting applies to organizations.
|
||||||
|
|||||||
Generated
+1
@@ -5922,6 +5922,7 @@ dependencies = [
|
|||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"html5gum",
|
"html5gum",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"ipnet",
|
||||||
"job_scheduler_ng",
|
"job_scheduler_ng",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ pico-args = "0.5.0"
|
|||||||
pastey = "0.2.3"
|
pastey = "0.2.3"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
|
||||||
|
# CIDR parsing for the trusted proxies of the client IP header
|
||||||
|
ipnet = "2.12.0"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", default-features = false }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
moka = { version = "0.12.15", features = ["future"] }
|
moka = { version = "0.12.15", features = ["future"] }
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ RUN USER=root cargo new --bin /app
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
{% if base == "alpine" %}
|
{% if base == "alpine" %}
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::{
|
|||||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
master_password_policy, register_push_device, unregister_push_device,
|
master_password_policy, register_push_device, unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers, decode_delete, decode_invite, decode_verify_email},
|
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
@@ -1197,7 +1197,9 @@ struct DeleteRecoverData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete-recover", data = "<data>")]
|
#[post("/accounts/delete-recover", data = "<data>")]
|
||||||
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult {
|
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data: DeleteRecoverData = data.into_inner();
|
let data: DeleteRecoverData = data.into_inner();
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -1270,9 +1272,11 @@ struct PasswordHintData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/password-hint", data = "<data>")]
|
#[post("/accounts/password-hint", data = "<data>")]
|
||||||
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||||
|
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||||
err!("This server is not configured to provide password hints.");
|
err!("This server is not configured to provide password hints.");
|
||||||
}
|
}
|
||||||
@@ -1510,7 +1514,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/devices/identifier/<device_id>/clear-token")]
|
#[put("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
// This only clears push token
|
// This only clears push token
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
||||||
@@ -1532,8 +1538,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
|||||||
|
|
||||||
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
||||||
#[post("/devices/identifier/<device_id>/clear-token")]
|
#[post("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
put_clear_device_token(device_id, conn).await
|
put_clear_device_token(device_id, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/tasks")]
|
#[get("/tasks")]
|
||||||
|
|||||||
@@ -629,7 +629,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
|
|
||||||
// Read and create the ciphers
|
// Read and create the ciphers
|
||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||||
@@ -1043,6 +1043,13 @@ async fn share_cipher_by_uuid(
|
|||||||
err!("Cipher doesn't exist")
|
err!("Cipher doesn't exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `update_cipher_from_data()` rejects this too, but only after the collections below were
|
||||||
|
// already linked. There are no transactions, so that would leave the cipher linked to a
|
||||||
|
// collection of another organization.
|
||||||
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
|
||||||
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
|
}
|
||||||
|
|
||||||
let mut shared_to_collections = vec![];
|
let mut shared_to_collections = vec![];
|
||||||
|
|
||||||
if let Some(organization_id) = &data.cipher.organization_id {
|
if let Some(organization_id) = &data.cipher.organization_id {
|
||||||
|
|||||||
@@ -182,7 +182,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
1600..=1699 => {
|
1600..=1699 => {
|
||||||
if let Some(org_id) = &event.organization_id {
|
// Only allow logging events for an organization the user is actually a member of.
|
||||||
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
||||||
|
{
|
||||||
log_event_impl(
|
log_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
org_id,
|
org_id,
|
||||||
@@ -197,8 +200,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
&& let Some(org_id) = cipher.organization_uuid
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
{
|
{
|
||||||
log_event_impl(
|
log_event_impl(
|
||||||
|
|||||||
@@ -577,6 +577,13 @@ async fn post_bulk_access_collections(
|
|||||||
err!("Can't find organization details")
|
err!("Can't find organization details")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The collections and members are checked below, the groups only here.
|
||||||
|
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||||
|
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
||||||
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
||||||
err!("Collection not found")
|
err!("Collection not found")
|
||||||
@@ -946,6 +953,11 @@ async fn get_members(
|
|||||||
if org_id != headers.membership.org_uuid {
|
if org_id != headers.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !headers.membership.has_full_access() {
|
||||||
|
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
|
||||||
|
}
|
||||||
|
|
||||||
let mut users_json = Vec::new();
|
let mut users_json = Vec::new();
|
||||||
for u in Membership::find_by_org(&org_id, &conn).await {
|
for u in Membership::find_by_org(&org_id, &conn).await {
|
||||||
users_json.push(
|
users_json.push(
|
||||||
@@ -1167,6 +1179,9 @@ async fn send_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for group_id in &data.groups {
|
for group_id in &data.groups {
|
||||||
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
|
||||||
|
err!("Group not found in Organization")
|
||||||
|
}
|
||||||
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
|
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
|
||||||
group_entry.save(&conn).await?;
|
group_entry.save(&conn).await?;
|
||||||
}
|
}
|
||||||
@@ -1614,6 +1629,9 @@ async fn edit_member(
|
|||||||
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
|
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
|
||||||
|
|
||||||
for group_id in data.groups.iter().flatten() {
|
for group_id in data.groups.iter().flatten() {
|
||||||
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
|
||||||
|
err!("Group not found in Organization")
|
||||||
|
}
|
||||||
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
|
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
|
||||||
group_entry.save(&conn).await?;
|
group_entry.save(&conn).await?;
|
||||||
}
|
}
|
||||||
@@ -1813,19 +1831,19 @@ async fn post_org_import(
|
|||||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||||
Cipher::validate_cipher_data(&data.ciphers)?;
|
Cipher::validate_cipher_data(&data.ciphers)?;
|
||||||
|
|
||||||
let existing_collections: HashSet<Option<CollectionId>> =
|
let existing_collections: HashMap<CollectionId, Collection> =
|
||||||
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect();
|
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
|
||||||
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
|
||||||
for col in data.collections {
|
for col in data.collections {
|
||||||
let collection_uuid = if existing_collections.contains(&col.id) {
|
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
|
||||||
let col_id = col.id.unwrap();
|
let collection_uuid = if let Some(collection) = existing {
|
||||||
// When not an Owner or Admin, check if the member is allowed to access the collection.
|
// When not an Owner or Admin, check if the member is allowed to write to the collection.
|
||||||
if headers.membership.atype < MembershipType::Admin
|
if headers.membership.atype < MembershipType::Admin
|
||||||
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await
|
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
|
||||||
{
|
{
|
||||||
err!(Compact, "The current user isn't allowed to manage this collection")
|
err!(Compact, "The current user isn't allowed to manage this collection")
|
||||||
}
|
}
|
||||||
col_id
|
collection.uuid.clone()
|
||||||
} else {
|
} else {
|
||||||
// We do not allow users or managers which can not manage all collections to create new collections
|
// We do not allow users or managers which can not manage all collections to create new collections
|
||||||
// If there is any collection other than an existing import collection, abort the import.
|
// If there is any collection other than an existing import collection, abort the import.
|
||||||
@@ -1870,8 +1888,9 @@ async fn post_org_import(
|
|||||||
|
|
||||||
// Assign the collections
|
// Assign the collections
|
||||||
for (cipher_index, col_index) in relations {
|
for (cipher_index, col_index) in relations {
|
||||||
let cipher_id = &ciphers[cipher_index];
|
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
|
||||||
let col_id = &collections[col_index];
|
err!(Compact, "Invalid collection relationship")
|
||||||
|
};
|
||||||
CollectionCipher::save(cipher_id, col_id, &conn).await?;
|
CollectionCipher::save(cipher_id, col_id, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2441,6 +2460,11 @@ async fn get_groups_data(
|
|||||||
if org_id != headers.membership.org_uuid {
|
if org_id != headers.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !headers.membership.has_full_access() {
|
||||||
|
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
|
||||||
|
}
|
||||||
|
|
||||||
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
||||||
let groups = Group::find_by_organization(&org_id, &conn).await;
|
let groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
let mut groups_json = Vec::with_capacity(groups.len());
|
let mut groups_json = Vec::with_capacity(groups.len());
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
|
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
|
||||||
OrganizationApiKey, OrganizationId, User,
|
OrganizationApiKey, OrganizationId, User,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,8 +84,15 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
}
|
}
|
||||||
// If user is part of the organization, restore it
|
// If user is part of the organization, restore it
|
||||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||||
let restored = member.restore();
|
let mut restored = member.restore();
|
||||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||||
|
// Enforce org policies as every other restore path does.
|
||||||
|
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
|
||||||
|
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
|
||||||
|
warn!("Not restoring {}: {e:?}", user_data.email);
|
||||||
|
member.revoke();
|
||||||
|
restored = false;
|
||||||
|
}
|
||||||
if restored || ext_modified {
|
if restored || ext_modified {
|
||||||
member.save(&conn).await?;
|
member.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -471,6 +471,8 @@ async fn post_access_legacy(
|
|||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
@@ -505,10 +507,12 @@ async fn post_access_legacy(
|
|||||||
|
|
||||||
// Files are incremented during the download
|
// Files are incremented during the download
|
||||||
if send.atype == SendType::Text as i32 {
|
if send.atype == SendType::Text as i32 {
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
send.save(&conn).await?;
|
send.save(&conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
process_access(send, conn, nt).await
|
process_access(send, conn, nt).await
|
||||||
}
|
}
|
||||||
@@ -548,8 +552,11 @@ async fn post_access_file_legacy(
|
|||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
host: Host,
|
host: Host,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
@@ -582,9 +589,9 @@ async fn post_access_file_legacy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
send.save(&conn).await?;
|
}
|
||||||
|
|
||||||
process_access_file(send, file_id, host, conn, nt).await
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,22 @@ async fn get_page(url: &str) -> Result<Response, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
||||||
|
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
|
||||||
|
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
|
||||||
|
let Ok(parsed_url) = url::Url::parse(url) else {
|
||||||
|
err_silent!("Invalid URL", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
err_silent!("Invalid scheme", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(host) = parsed_url.host() else {
|
||||||
|
err_silent!("Invalid host", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
should_block_host(&host)?;
|
||||||
|
|
||||||
let mut client = CLIENT.get(url);
|
let mut client = CLIENT.get(url);
|
||||||
if !referer.is_empty() {
|
if !referer.is_empty() {
|
||||||
client = client.header("Referer", referer);
|
client = client.header("Referer", referer);
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async fn login(
|
|||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
"send_access" => {
|
"send_access" => {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
|
||||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
|
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
|
||||||
|
|
||||||
@@ -1055,8 +1056,11 @@ enum RegisterVerificationResponse {
|
|||||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||||
async fn register_verification_email(
|
async fn register_verification_email(
|
||||||
data: Json<RegisterVerificationData>,
|
data: Json<RegisterVerificationData>,
|
||||||
|
ip: ClientIp,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> ApiResult<RegisterVerificationResponse> {
|
) -> ApiResult<RegisterVerificationResponse> {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
// the registration can only continue if signup is allowed or there exists an invitation
|
// the registration can only continue if signup is allowed or there exists an invitation
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
|||||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||||
Arc::new(AnonymousWebSocketSubscriptions {
|
Arc::new(AnonymousWebSocketSubscriptions {
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
|
connections: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
|
||||||
|
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||||
|
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||||
|
|
||||||
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
|
|||||||
struct WSAnonymousEntryMapGuard {
|
struct WSAnonymousEntryMapGuard {
|
||||||
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
token: String,
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
addr: IpAddr,
|
addr: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WSAnonymousEntryMapGuard {
|
impl WSAnonymousEntryMapGuard {
|
||||||
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self {
|
fn new(
|
||||||
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
|
addr: IpAddr,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
token,
|
token,
|
||||||
|
entry_uuid,
|
||||||
addr,
|
addr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +110,11 @@ impl WSAnonymousEntryMapGuard {
|
|||||||
impl Drop for WSAnonymousEntryMapGuard {
|
impl Drop for WSAnonymousEntryMapGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
info!("Closing WS connection from {}", self.addr);
|
info!("Closing WS connection from {}", self.addr);
|
||||||
self.subscriptions.map.remove(&self.token);
|
if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
|
||||||
|
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
|
||||||
|
}
|
||||||
|
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
|
||||||
|
self.subscriptions.release(self.addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
let (mut rx, guard) = {
|
let (mut rx, guard) = {
|
||||||
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
||||||
|
|
||||||
// Add a channel to send messages to this client to the map
|
if !subscriptions.try_reserve(ip.ip) {
|
||||||
|
err_code!("Too many connections", 429)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a channel to send messages to this client to the map.
|
||||||
|
// Clients reconnect with the same token while a login request is still pending, so keep
|
||||||
|
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
||||||
subscriptions.map.insert(token.clone(), tx);
|
let entry_uuid = uuid::Uuid::new_v4();
|
||||||
|
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
|
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -534,17 +557,44 @@ impl WebSocketUsers {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AnonymousWebSocketSubscriptions {
|
pub struct AnonymousWebSocketSubscriptions {
|
||||||
map: Arc<dashmap::DashMap<String, Sender<Message>>>,
|
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
|
||||||
|
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnonymousWebSocketSubscriptions {
|
impl AnonymousWebSocketSubscriptions {
|
||||||
|
/// Takes a connection slot for this address, returns false when it already reached the limit.
|
||||||
|
fn try_reserve(&self, addr: IpAddr) -> bool {
|
||||||
|
let mut count = self.connections.entry(addr).or_insert(0);
|
||||||
|
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a slot taken by `try_reserve`.
|
||||||
|
fn release(&self, addr: IpAddr) {
|
||||||
|
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
*count == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// Only remove once the guard above is dropped, otherwise this deadlocks.
|
||||||
|
if empty {
|
||||||
|
self.connections.remove_if(&addr, |_, count| *count == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_update(&self, token: &str, data: &[u8]) {
|
async fn send_update(&self, token: &str, data: &[u8]) {
|
||||||
if let Some(sender) = self.map.get(token).map(|v| v.clone())
|
// Clone the senders so the map isn't kept locked while sending.
|
||||||
&& let Err(e) = sender.send(Message::binary(data)).await
|
let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
|
||||||
{
|
for (_, sender) in senders {
|
||||||
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) {
|
pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) {
|
||||||
if !CONFIG.enable_websocket() {
|
if !CONFIG.enable_websocket() {
|
||||||
|
|||||||
+40
-16
@@ -10,6 +10,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
use ipnet::IpNet;
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use openssl::rsa::Rsa;
|
use openssl::rsa::Rsa;
|
||||||
@@ -1054,12 +1055,44 @@ pub struct ClientIp {
|
|||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
|
||||||
|
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
|
||||||
|
let entry = entry.trim();
|
||||||
|
match entry.parse::<IpNet>() {
|
||||||
|
Ok(net) => Some(net),
|
||||||
|
// Without a prefix length it is a single address, which is a valid way to write this.
|
||||||
|
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
|
||||||
|
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
|
||||||
|
let trusted = CONFIG.ip_header_trusted_proxies();
|
||||||
|
let trusted = trusted.trim();
|
||||||
|
if trusted.eq_ignore_ascii_case("all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(remote) = remote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
|
||||||
|
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
|
||||||
|
let remote = remote.to_canonical();
|
||||||
|
if trusted.eq_ignore_ascii_case("local") {
|
||||||
|
return !crate::util::is_global(remote);
|
||||||
|
}
|
||||||
|
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for ClientIp {
|
impl<'r> FromRequest<'r> for ClientIp {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = if CONFIG._ip_header_enabled() {
|
let remote = req.remote().map(|r| r.ip());
|
||||||
|
|
||||||
|
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
|
||||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||||
match ip.find(',') {
|
match ip.find(',') {
|
||||||
Some(idx) => &ip[..idx],
|
Some(idx) => &ip[..idx],
|
||||||
@@ -1070,10 +1103,13 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
|
||||||
|
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
|
||||||
|
}
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
||||||
|
|
||||||
Outcome::Success(ClientIp {
|
Outcome::Success(ClientIp {
|
||||||
ip,
|
ip,
|
||||||
@@ -1268,20 +1304,8 @@ pub async fn refresh_tokens(
|
|||||||
) -> ApiResult<(Device, AuthTokens)> {
|
) -> ApiResult<(Device, AuthTokens)> {
|
||||||
let refresh_claims = match decode_refresh(refresh_token) {
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
|
||||||
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
err_silent!("Invalid refresh token")
|
||||||
|
|
||||||
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
|
||||||
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
|
||||||
// check expiration or issuer, so they're not included here.
|
|
||||||
RefreshJwtClaims {
|
|
||||||
nbf: 0,
|
|
||||||
exp: 0,
|
|
||||||
iss: String::new(),
|
|
||||||
sub: AuthMethod::Password,
|
|
||||||
device_token: refresh_token.into(),
|
|
||||||
token: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(claims) => claims,
|
Ok(claims) => claims,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-2
@@ -113,8 +113,9 @@ impl SendTokens {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send.access_count += 1;
|
if !send.register_access(conn).await? {
|
||||||
send.save(conn).await?;
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
access_claims: generate_send_access_claims(&send_id),
|
access_claims: generate_send_access_claims(&send_id),
|
||||||
|
|||||||
@@ -666,6 +666,12 @@ make_config! {
|
|||||||
ip_header: String, true, def, "X-Real-IP".to_owned();
|
ip_header: String, true, def, "X-Real-IP".to_owned();
|
||||||
/// Internal IP header property, used to avoid recomputing each time
|
/// Internal IP header property, used to avoid recomputing each time
|
||||||
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||||
|
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
|
||||||
|
/// other address use the remote IP instead, so a client can't spoof the header.
|
||||||
|
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
|
||||||
|
/// running on the same host or container network), the string "all" to accept it from anywhere,
|
||||||
|
/// or a comma separated list of IPs and CIDR ranges.
|
||||||
|
ip_header_trusted_proxies: String, true, def, "local".to_owned();
|
||||||
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
||||||
@@ -768,6 +774,11 @@ make_config! {
|
|||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u32, false, def, 10;
|
login_ratelimit_max_burst: u32, false, def, 10;
|
||||||
|
|
||||||
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
|
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||||
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
|
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u64, false, def, 300;
|
admin_ratelimit_seconds: u64, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
@@ -942,6 +953,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
|
||||||
|
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
|
||||||
|
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
|
||||||
|
if crate::auth::parse_trusted_proxy(entry).is_none() {
|
||||||
|
err!(format!(
|
||||||
|
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
|
||||||
|
entry.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.password_iterations < 100_000 {
|
if cfg.password_iterations < 100_000 {
|
||||||
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,6 +306,16 @@ impl Cipher {
|
|||||||
type_data_json = Value::Null;
|
type_data_json = Value::Null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone the type_data and add some default value.
|
||||||
|
let mut data_json = type_data_json.clone();
|
||||||
|
|
||||||
|
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
||||||
|
// data_json should always contain the following keys with every atype
|
||||||
|
data_json["fields"] = json!(fields_json);
|
||||||
|
data_json["name"] = json!(self.name);
|
||||||
|
data_json["notes"] = json!(self.notes);
|
||||||
|
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
|
||||||
|
|
||||||
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
||||||
Cow::from(cipher_collections)
|
Cow::from(cipher_collections)
|
||||||
@@ -345,6 +355,8 @@ impl Cipher {
|
|||||||
"notes": self.notes,
|
"notes": self.notes,
|
||||||
"fields": fields_json,
|
"fields": fields_json,
|
||||||
|
|
||||||
|
"data": data_json,
|
||||||
|
|
||||||
"passwordHistory": password_history_json,
|
"passwordHistory": password_history_json,
|
||||||
|
|
||||||
// All Cipher types are included by default as null, but only the matching one will be populated
|
// All Cipher types are included by default as null, but only the matching one will be populated
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ impl Group {
|
|||||||
groups::table
|
groups::table
|
||||||
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
|
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
|
||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
|
users_organizations::table.on(users_organizations::uuid
|
||||||
|
.eq(groups_users::users_organizations_uuid)
|
||||||
|
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
|
||||||
)
|
)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
|
|||||||
@@ -231,6 +231,37 @@ impl Send {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers an access, incrementing `access_count` only while below `max_access_count`.
|
||||||
|
/// Returns false when the limit was already reached. The check and the increment are a single
|
||||||
|
/// statement, otherwise concurrent accesses can both pass the check and exceed the limit.
|
||||||
|
pub async fn register_access(&mut self, conn: &DbConn) -> Result<bool, crate::Error> {
|
||||||
|
self.update_users_revision(conn).await;
|
||||||
|
|
||||||
|
let revision_date = Utc::now().naive_utc();
|
||||||
|
let uuid = self.uuid.clone();
|
||||||
|
let updated = conn
|
||||||
|
.run(move |conn| {
|
||||||
|
diesel::update(sends::table)
|
||||||
|
.filter(sends::uuid.eq(uuid))
|
||||||
|
.filter(
|
||||||
|
sends::max_access_count
|
||||||
|
.is_null()
|
||||||
|
.or(sends::access_count.nullable().lt(sends::max_access_count)),
|
||||||
|
)
|
||||||
|
.set((sends::access_count.eq(sends::access_count + 1), sends::revision_date.eq(revision_date)))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.access_count += 1;
|
||||||
|
self.revision_date = revision_date;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
self.update_users_revision(conn).await;
|
self.update_users_revision(conn).await;
|
||||||
|
|
||||||
|
|||||||
+27
-8
@@ -174,6 +174,27 @@ pub enum CustomHttpClientError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CustomHttpClientError {
|
impl CustomHttpClientError {
|
||||||
|
/// Attach the domain that resolved to this address, which `should_block_host()` can't know.
|
||||||
|
fn with_domain(self, name: &str) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::NonGlobalIp {
|
||||||
|
ip,
|
||||||
|
..
|
||||||
|
} => Self::NonGlobalIp {
|
||||||
|
domain: Some(name.to_owned()),
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
Self::Blocked {
|
||||||
|
domain,
|
||||||
|
} => Self::Blocked {
|
||||||
|
domain: format!("{name} ({domain})"),
|
||||||
|
},
|
||||||
|
other @ Self::Invalid {
|
||||||
|
..
|
||||||
|
} => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
|
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
|
||||||
let mut source = e.source();
|
let mut source = e.source();
|
||||||
|
|
||||||
@@ -285,14 +306,12 @@ fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
|
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
|
||||||
if should_block_ip(ip) {
|
let host: Host<&str> = match ip {
|
||||||
Err(CustomHttpClientError::NonGlobalIp {
|
IpAddr::V4(ip) => Host::Ipv4(ip),
|
||||||
domain: Some(name.to_owned()),
|
IpAddr::V6(ip) => Host::Ipv6(ip),
|
||||||
ip,
|
};
|
||||||
})
|
|
||||||
} else {
|
should_block_host(&host).map_err(|e| e.with_domain(name))
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Resolve for CustomDns {
|
impl Resolve for CustomDns {
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
|||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
|
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
|
||||||
|
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
|
||||||
|
.expect("Non-zero unauthenticated ratelimit burst");
|
||||||
|
RateLimiter::keyed(
|
||||||
|
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn check_limit_unauthenticated(ip: &IpAddr) -> Result<(), Error> {
|
||||||
|
match LIMITER_UNAUTHENTICATED.check_key(ip) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_e) => {
|
||||||
|
err_code!("Too many requests", 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
|
pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
|
||||||
match LIMITER_LOGIN.check_key(ip) {
|
match LIMITER_LOGIN.check_key(ip) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = response.bytes().await.map_err(Box::new)?;
|
let body = response.bytes().await.map_err(Box::new)?;
|
||||||
|
if CONFIG.sso_debug_tokens() {
|
||||||
debug!("Response body {}", String::from_utf8_lossy(&body));
|
debug!("Response body {}", String::from_utf8_lossy(&body));
|
||||||
|
}
|
||||||
builder.body(body.to_vec()).map_err(HttpClientError::Http)
|
builder.body(body.to_vec()).map_err(HttpClientError::Http)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ app-security > app-two-factor-setup > form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide unsupported Custom Role options */
|
/* Hide unsupported Custom Role options */
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
bit-dialog div.tw-ml-4:has(bit-form-control input),
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
|
bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
|
||||||
@extend %vw-hide;
|
@extend %vw-hide;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user