fix: stricter public reads
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user