Skip to content
Merged
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
15 changes: 14 additions & 1 deletion crates/defguard_core/src/enterprise/handlers/api_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
enterprise::db::models::api_tokens::{ApiToken, ApiTokenInfo},
error::WebError,
events::{ApiEvent, ApiEventType, ApiRequestContext},
handlers::{ApiResponse, ApiResult, user_for_admin_or_self},
handlers::{ApiResponse, ApiResult, user_for_admin_or_self, validate_name},
};

const API_TOKEN_LENGTH: usize = 32;
Expand Down Expand Up @@ -61,6 +61,12 @@ pub async fn add_api_token(

// TODO: check if the name is already used

if !validate_name(&data.name) {
return Err(WebError::BadRequest(
"Name contains forbidden characters".into(),
));
}

// generate token string
// all API tokens start with a `dg-` prefix
let token_string = format!("dg-{}", gen_alphanumeric(API_TOKEN_LENGTH));
Expand Down Expand Up @@ -156,6 +162,13 @@ pub async fn rename_api_token(
Json(data): Json<RenameRequest>,
) -> ApiResult {
debug!("Renaming API token {token_id} for user {username}");

if !validate_name(&data.name) {
return Err(WebError::BadRequest(
"Name contains forbidden characters".into(),
));
}

let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?;
if let Some(mut token) = ApiToken::find_by_id(&appstate.pool, token_id).await? {
if !session.is_admin && user.id != token.user_id {
Expand Down
10 changes: 10 additions & 0 deletions crates/defguard_core/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,16 @@ pub async fn device_for_admin_or_self<'e, E: sqlx::PgExecutor<'e>>(
}
}

/// Validate name provided by user
pub fn validate_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let allowed_symbols = [' ', '-', '_', '.', '(', ')', ':', '/'];
name.chars()
.all(|c| c.is_alphanumeric() || allowed_symbols.contains(&c))
}

impl<S> FromRequestParts<S> for ApiRequestContext
where
S: Send + Sync,
Expand Down
61 changes: 61 additions & 0 deletions crates/defguard_core/tests/integration/api/api_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,64 @@ async fn dg25_3_test_token_invalidation(_: PgPoolOptions, options: PgConnectOpti
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test]
async fn dg26_8_test_add_api_token(_: PgPoolOptions, options: PgConnectOptions) {
let pool = setup_pool(options).await;

let client = make_client(pool).await;

// log in as admin
let auth = Auth::new("admin", "pass123");
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);

// HTML injection payloads must be rejected
let forbidden_names = [
"<script>alert(1)</script>",
"<h1><a href='x'>click</a></h1>",
"token&name",
"token\"name",
"token`name",
"read:users<admin>",
"ci/cd&deploy",
];

for name in forbidden_names {
let response = client
.post("/api/v1/user/admin/api_token")
.json(&AddApiTokenData { name: name.into() })
.send()
.await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"expected 400 for name: {name:?}"
);
}

// safe names must be accepted
let allowed_names = [
"my-token",
"token_1",
"Token 2026",
"ci.deploy",
"read-only (prod)",
"read:users",
"ci/cd",
"env:prod/deploy",
];

for name in allowed_names {
let response = client
.post("/api/v1/user/admin/api_token")
.json(&AddApiTokenData { name: name.into() })
.send()
.await;
assert_eq!(
response.status(),
StatusCode::CREATED,
"expected 201 for name: {name:?}"
);
}
}
5 changes: 4 additions & 1 deletion web/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
auditConfig: {}
allowBuilds:
'@parcel/watcher': true
esbuild: true
sharp: true

onlyBuiltDependencies:
- '@parcel/watcher'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../../../../../../../shared/hooks/modalControls/modalsSubjects';
import { ModalName } from '../../../../../../../shared/hooks/modalControls/modalTypes';
import type { OpenAddApiTokenModal } from '../../../../../../../shared/hooks/modalControls/types';
import { nameValidator } from '../../../../../../../shared/validators';

const modalNameKey = ModalName.AddApiToken;

Expand Down Expand Up @@ -56,7 +57,7 @@ export const AddApiTokenModal = () => {
};

const formSchema = z.object({
name: z.string().trim().min(1, m.form_error_required()),
name: z.string().trim().min(1, m.form_error_required()).and(nameValidator),
});

type FormFields = z.infer<typeof formSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../../../../../../shared/hooks/modalControls/modalsSubjects';
import { ModalName } from '../../../../../../../shared/hooks/modalControls/modalTypes';
import type { OpenRenameApiTokenModal } from '../../../../../../../shared/hooks/modalControls/types';
import { nameValidator } from '../../../../../../../shared/validators';

const modalNameKey = ModalName.RenameApiToken;

Expand Down Expand Up @@ -47,7 +48,7 @@ export const RenameApiTokenModal = () => {
};

const formSchema = z.object({
name: z.string().trim().min(1, m.form_error_required()),
name: z.string().trim().min(1, m.form_error_required()).and(nameValidator),
});

const ModalContent = ({ id, name, username }: OpenRenameApiTokenModal) => {
Expand Down
5 changes: 5 additions & 0 deletions web/src/shared/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,8 @@ export const aclDestinationValidator = z
(value: string) => parseAclDestinations(value) !== null,
m.form_error_invalid(),
);

// Allows Unicode letters, Unicode digits, space, hyphen, underscore, dot, parentheses, colon, slash
const namePattern = /^[\p{L}\p{N} \-_.():\/]+$/u;

export const nameValidator = z.string().regex(namePattern, m.form_error_forbidden_char());
Loading