fix: stricter public reads

This commit is contained in:
Vlad Stan
2026-07-16 15:29:56 +03:00
parent 58ccdb7212
commit b678773728
8 changed files with 257 additions and 46 deletions
+51 -9
View File
@@ -52,6 +52,7 @@ from .models import (
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
@@ -128,7 +129,7 @@ class ExtensionHostAPI:
async def storage_get_public(
self, request: StorageGetRequest
) -> StorageGetResponse:
public_fields = self._public_storage_fields(request.table)
public_fields = self._public_storage_policy(request.table)["public_fields"]
row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row:
return StorageGetResponse()
@@ -239,14 +240,23 @@ class ExtensionHostAPI:
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePaginatedRequest
self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse:
public_fields = self._public_storage_fields(request.table)
self._validate_public_storage_query_fields(request, public_fields)
policy = self._public_storage_policy(request.table)
public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
request.filters,
filters,
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
@@ -737,7 +747,7 @@ class ExtensionHostAPI:
return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]:
def _public_storage_policy(self, table: str) -> dict[str, Any]:
tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables:
raise PermissionError(
@@ -757,24 +767,56 @@ class ExtensionHostAPI:
raise PermissionError(
f"Public storage table '{table}' has no valid public fields."
)
return set(public_fields)
source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self, request: StoragePaginatedRequest, public_fields: set[str]
self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields)
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy(
self, table: str, source_id: str
) -> tuple[dict[str, Any], str]:
+4
View File
@@ -109,6 +109,10 @@ class StoragePaginatedRequest(BaseModel):
return values
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
+24 -6
View File
@@ -210,29 +210,47 @@ def _public_storage_grant_is_subset(
) -> bool:
requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_fields in granted_tables.items():
requested_fields = requested_tables.get(table_name)
if requested_fields is None or not granted_fields.issubset(requested_fields):
for table_name, granted_policy in granted_tables.items():
requested_policy = requested_tables.get(table_name)
if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
tables: dict[str, set[str]] = {}
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, dict[str, Any]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = fields
tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
return tables
+2
View File
@@ -549,6 +549,8 @@ window.localisation.en = {
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
@@ -174,7 +174,12 @@
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : null
})
.filter(Boolean)
}
@@ -9,28 +9,31 @@
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<q-item-label
class="text-weight-medium col-auto"
style="max-width: 100%"
>
<span v-text="permission.label"></span>
</q-item-label>
<div
v-if="permission.risk.level !== 'low' || permission.badges.length"
class="row items-center q-gutter-xs col-auto q-mb-xs"
style="max-width: 100%"
>
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</div>
</q-item-section>
</template>
@@ -57,7 +60,24 @@
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
+88 -7
View File
@@ -12,7 +12,7 @@ from lnbits.core.wasm_ext.api.models import (
PayInvoiceRequest,
StorageAppendPublicRequest,
StorageGetRequest,
StoragePaginatedRequest,
StoragePublicPaginatedRequest,
WalletBalanceRequest,
WebsocketPublishRequest,
)
@@ -79,6 +79,7 @@ async def test_host_api_filters_public_paginated_storage_rows(
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"],
}
],
@@ -87,15 +88,16 @@ async def test_host_api_filters_public_paginated_storage_rows(
)
response = await api.storage_get_public_paginated(
StoragePaginatedRequest(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-1"},
filters={},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@@ -103,11 +105,55 @@ async def test_host_api_filters_public_paginated_storage_rows(
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once()
storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI(
"demoext",
[
@@ -123,17 +169,52 @@ async def test_host_api_public_paginated_storage_rejects_private_query_fields():
],
)
with pytest.raises(PermissionError, match="non-public fields"):
with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated(
StoragePaginatedRequest(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
filters={},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@@ -65,6 +65,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
"policies": [
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title", "description"],
}
],
@@ -80,6 +81,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -95,6 +97,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -102,6 +105,42 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
]
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"policies": [
{
"table_name": "messages",
"source_id_field": "conversation_id",
"public_fields": ["id", "body"],
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "body"],
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(