Skip to content

Commit 4945696

Browse files
committed
Validate secrets
1 parent d144212 commit 4945696

4 files changed

Lines changed: 64 additions & 8 deletions

File tree

src/dstack/_internal/server/migrations/versions/8d99ec1a4e87_add_secretmodel.py renamed to src/dstack/_internal/server/migrations/versions/7b754317d91e_add_secretmodel.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Add SecretModel
22
3-
Revision ID: 8d99ec1a4e87
3+
Revision ID: 7b754317d91e
44
Revises: 35e90e1b0d3e
5-
Create Date: 2025-06-26 14:04:45.440433
5+
Create Date: 2025-06-30 10:20:07.288474
66
77
"""
88

@@ -13,7 +13,7 @@
1313
import dstack._internal.server.models
1414

1515
# revision identifiers, used by Alembic.
16-
revision = "8d99ec1a4e87"
16+
revision = "7b754317d91e"
1717
down_revision = "35e90e1b0d3e"
1818
branch_labels = None
1919
depends_on = None
@@ -30,9 +30,7 @@ def upgrade() -> None:
3030
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
3131
sa.Column("updated_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
3232
sa.Column("name", sa.String(length=200), nullable=False),
33-
sa.Column(
34-
"value", dstack._internal.server.models.EncryptedString(length=2000), nullable=False
35-
),
33+
sa.Column("value", dstack._internal.server.models.EncryptedString(), nullable=False),
3634
sa.ForeignKeyConstraint(
3735
["project_id"],
3836
["projects.id"],

src/dstack/_internal/server/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,4 +728,4 @@ class SecretModel(BaseModel):
728728
updated_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
729729

730730
name: Mapped[str] = mapped_column(String(200))
731-
value: Mapped[DecryptedString] = mapped_column(EncryptedString(2000))
731+
value: Mapped[DecryptedString] = mapped_column(EncryptedString())

src/dstack/_internal/server/services/secrets.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1+
import re
12
from typing import Dict, List, Optional
23

34
import sqlalchemy.exc
45
from sqlalchemy import delete, select, update
56
from sqlalchemy.ext.asyncio import AsyncSession
67

7-
from dstack._internal.core.errors import ResourceExistsError, ResourceNotExistsError
8+
from dstack._internal.core.errors import (
9+
ResourceExistsError,
10+
ResourceNotExistsError,
11+
ServerClientError,
12+
)
813
from dstack._internal.core.models.secrets import Secret
914
from dstack._internal.server.models import DecryptedString, ProjectModel, SecretModel
1015
from dstack._internal.utils.logging import get_logger
1116

1217
logger = get_logger(__name__)
1318

1419

20+
_SECRET_NAME_REGEX = "^[A-Za-z0-9-_]{1,200}$"
21+
_SECRET_VALUE_MAX_LENGTH = 2000
22+
23+
1524
async def list_secrets(
1625
session: AsyncSession,
1726
project: ProjectModel,
@@ -49,6 +58,7 @@ async def create_or_update_secret(
4958
name: str,
5059
value: str,
5160
) -> Secret:
61+
_validate_secret(name=name, value=value)
5262
try:
5363
secret_model = await create_secret(
5464
session=session,
@@ -177,3 +187,18 @@ async def update_secret(
177187
if secret_model is None:
178188
raise ResourceNotExistsError()
179189
return secret_model
190+
191+
192+
def _validate_secret(name: str, value: str):
193+
_validate_secret_name(name)
194+
_validate_secret_value(value)
195+
196+
197+
def _validate_secret_name(name: str):
198+
if re.match(_SECRET_NAME_REGEX, name) is None:
199+
raise ServerClientError(f"Secret name should match regex '{_SECRET_NAME_REGEX}")
200+
201+
202+
def _validate_secret_value(value: str):
203+
if len(value) > _SECRET_VALUE_MAX_LENGTH:
204+
raise ServerClientError(f"Secret value length must not exceed {_SECRET_VALUE_MAX_LENGTH}")

src/tests/_internal/server/routers/test_secrets.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,39 @@ async def test_updates_secret(self, test_db, session: AsyncSession, client: Asyn
166166
await session.refresh(secret)
167167
assert secret.value.get_plaintext_or_error() == "new_value"
168168

169+
@pytest.mark.asyncio
170+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
171+
@pytest.mark.parametrize(
172+
"name, value",
173+
[
174+
("too_long_secret_value", "a" * 2001),
175+
("", "empty_name"),
176+
("@7&.", "wierd_name_chars"),
177+
],
178+
)
179+
async def test_rejects_bad_names_values(
180+
self,
181+
test_db,
182+
session: AsyncSession,
183+
client: AsyncClient,
184+
name: str,
185+
value,
186+
):
187+
user = await create_user(session=session, global_role=GlobalRole.USER)
188+
project = await create_project(session=session, owner=user)
189+
await add_project_member(
190+
session=session, project=project, user=user, project_role=ProjectRole.ADMIN
191+
)
192+
response = await client.post(
193+
f"/api/project/{project.name}/secrets/create_or_update",
194+
headers=get_auth_headers(user.token),
195+
json={"name": name, "value": value},
196+
)
197+
assert response.status_code == 400
198+
res = await session.execute(select(SecretModel))
199+
secret_model = res.scalar()
200+
assert secret_model is None
201+
169202

170203
class TestDeleteSecrets:
171204
@pytest.mark.asyncio

0 commit comments

Comments
 (0)