Skip to content
Open
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
18 changes: 10 additions & 8 deletions src/features/storageprogram/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import log from "@/utilities/logger"
import Datasource from "@/model/datasource"
import { GCRStorageProgram } from "@/model/entities/GCRv2/GCR_StorageProgram"
import { GCRStorageProgramRoutines } from "@/libs/blockchain/gcr/gcr_routines/GCRStorageProgramRoutines"
import { getAuthContext } from "@/libs/network/authContext"

// ============================================================================
// Response Types
Expand Down Expand Up @@ -114,14 +115,15 @@ interface StorageProgramGranularResponse {
* behaviour with the SQL ACL filter and other call sites that distinguish
* `""` from `undefined`.
*/
function getRequesterAddress(req: Request): string | undefined {
const identity = req.headers.get("identity")
if (!identity || identity.length === 0) {
return undefined
}
const splits = identity.split(":")
const candidate = splits.length > 1 ? splits[1] : identity
return candidate && candidate.length > 0 ? candidate : undefined
// Exported for tests.
export function getRequesterAddress(req: Request): string | undefined {
// The requester is the key the auth middleware VERIFIED for this request —
// never the raw `identity` header. The middleware only populates the auth
// context after checking the request signature (and 401s on a bad one), so
// a caller who merely names an allowlisted address in the header, with no
// signature, has no auth context and reads as anonymous. Trusting the raw
// header here was the storage read-ACL bypass.
return getAuthContext(req).publicKey ?? undefined
}

function getValueType(
Expand Down
14 changes: 11 additions & 3 deletions src/libs/network/manageGCRRoutines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export default async function manageGCRRoutines(
// Handle the payload
const { method, params } = payload

// The requester for every storage-program read ACL check is the key the RPC
// layer VERIFIED for this call (`sender`), never a caller-supplied params
// value: naming an allowlisted address without proving key ownership was the
// storage read-ACL bypass. Empty sender (anonymous) resolves to undefined,
// so restricted programs stay hidden unless the caller signed the request.
const verifiedRequester =
typeof sender === "string" && sender.length > 0 ? sender : undefined

switch (method) {
// SECTION XM Identity Management

Expand Down Expand Up @@ -290,7 +298,7 @@ export default async function manageGCRRoutines(
// REVIEW: Get storage program by address
case "getStorageProgram": {
const storageAddress = params[0]
const requesterAddress = params[1] // Optional identity for ACL check
const requesterAddress = verifiedRequester // verified signer, not caller-supplied

if (!storageAddress) {
response.result = 400
Expand Down Expand Up @@ -362,7 +370,7 @@ export default async function manageGCRRoutines(
// REVIEW: Get storage programs by owner
case "getStorageProgramsByOwner": {
const owner = params[0]
const requesterAddress = params[1] // Optional identity for ACL filtering
const requesterAddress = verifiedRequester // verified signer, not caller-supplied
const options = params[2] || {} // Optional { limit, offset }

if (!owner) {
Expand Down Expand Up @@ -425,7 +433,7 @@ export default async function manageGCRRoutines(
case "searchStoragePrograms": {
const query = params[0]
const options = params[1] || {} // { limit, offset, exactMatch }
const requesterAddress = params[2] // Optional identity for ACL filtering
const requesterAddress = verifiedRequester // verified signer, not caller-supplied

if (!query || (typeof query === "string" && query.trim() === "")) {
response.result = 400
Expand Down
12 changes: 4 additions & 8 deletions src/libs/network/routines/nodecalls/getStorageProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

interface GetStorageProgramData {
storageAddress?: unknown
requesterAddress?: unknown
auth?: unknown
Comment thread
tcsenpai marked this conversation as resolved.
}

/**
Expand All @@ -20,20 +20,16 @@ interface GetStorageProgramData {
* cross-repo contract).
*
* Enforces ACL via checkReadPermission. Anonymous callers can read public
* programs; restricted/owner programs require requesterAddress.
* programs; restricted/owner programs require a signed `auth` envelope
* proving control of an allowlisted address.
*/
export default async function getStorageProgram(
data: GetStorageProgramData,
): Promise<RPCResponse> {
try {
const requesterAddress =
typeof data?.requesterAddress === "string"
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) {
return result.error
Expand Down
9 changes: 2 additions & 7 deletions src/libs/network/routines/nodecalls/getStorageProgramAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

interface GetStorageProgramAllData {
storageAddress?: unknown
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -26,14 +26,9 @@ export default async function getStorageProgramAll(
data: GetStorageProgramAllData,
): Promise<RPCResponse> {
try {
const requesterAddress =
typeof data?.requesterAddress === "string"
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) return result.error

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

interface GetStorageProgramFieldsData {
storageAddress?: unknown
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -22,14 +22,9 @@ export default async function getStorageProgramFields(
data: GetStorageProgramFieldsData,
): Promise<RPCResponse> {
try {
const requesterAddress =
typeof data?.requesterAddress === "string"
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) return result.error

Expand Down
9 changes: 2 additions & 7 deletions src/libs/network/routines/nodecalls/getStorageProgramItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface GetStorageProgramItemData {
storageAddress?: unknown
field?: unknown
index?: unknown
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -35,14 +35,9 @@ export default async function getStorageProgramItem(
}
const rawIndex = Math.trunc(data.index)

const requesterAddress =
typeof data?.requesterAddress === "string"
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) return result.error

Expand Down
15 changes: 9 additions & 6 deletions src/libs/network/routines/nodecalls/getStorageProgramsByOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import {
rpcInternalError,
toStorageProgramListItem,
} from "./storageProgramShared"
import { readAuthScope, resolveReadRequester } from "./storageReadAuth"

interface GetStorageProgramsByOwnerData {
owner?: unknown
requesterAddress?: unknown
auth?: unknown
limit?: unknown
offset?: unknown
}
Expand All @@ -38,11 +39,13 @@ export default async function getStorageProgramsByOwner(
}
const owner = data.owner

const requesterAddress =
typeof data?.requesterAddress === "string" &&
data.requesterAddress.length > 0
? data.requesterAddress
: undefined
// Requester derived from a verified signature only — a plain address
// string is never trusted, so restricted programs stay hidden from
// anonymous callers (only public rows survive the SQL ACL filter).
const requesterAddress = await resolveReadRequester(
readAuthScope.owner(owner),
data?.auth,
)

const rawLimit = typeof data?.limit === "number" ? data.limit : 100
const limit = Math.min(Math.max(1, Math.floor(rawLimit)), 200)
Expand Down
9 changes: 2 additions & 7 deletions src/libs/network/routines/nodecalls/hasStorageProgramField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
interface HasStorageProgramFieldData {
storageAddress?: unknown
field?: unknown
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -31,14 +31,9 @@ export default async function hasStorageProgramField(
}
const field = data.field

const requesterAddress =
typeof data?.requesterAddress === "string"
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) return result.error

Expand Down
15 changes: 9 additions & 6 deletions src/libs/network/routines/nodecalls/searchStoragePrograms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
rpcInternalError,
toStorageProgramListItem,
} from "./storageProgramShared"
import { readAuthScope, resolveReadRequester } from "./storageReadAuth"

interface SearchStorageProgramsOptions {
limit?: unknown
Expand All @@ -19,7 +20,7 @@ interface SearchStorageProgramsOptions {
interface SearchStorageProgramsData {
query?: unknown
options?: SearchStorageProgramsOptions
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -46,11 +47,13 @@ export default async function searchStoragePrograms(
}
const query = data.query.trim()

const requesterAddress =
typeof data?.requesterAddress === "string" &&
data.requesterAddress.length > 0
? data.requesterAddress
: undefined
// Requester derived from a verified signature only — a plain address
// string is never trusted, so restricted programs stay hidden from
// anonymous callers (only public rows survive the SQL ACL filter).
const requesterAddress = await resolveReadRequester(
readAuthScope.search(query),
data?.auth,
)

const opts = data?.options ?? {}
const rawLimit = typeof opts.limit === "number" ? opts.limit : 100
Expand Down
42 changes: 26 additions & 16 deletions src/libs/network/routines/nodecalls/storageProgramShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Datasource from "src/model/datasource"
import { GCRStorageProgram } from "src/model/entities/GCRv2/GCR_StorageProgram"
import { GCRStorageProgramRoutines } from "src/libs/blockchain/gcr/gcr_routines/GCRStorageProgramRoutines"
import log from "src/utilities/logger"
import { readAuthScope, resolveReadRequester } from "./storageReadAuth"

export type StorageFieldType =
| "string"
Expand Down Expand Up @@ -93,6 +94,13 @@ export async function getStorageProgramRepository() {
/**
* Validate a storageAddress and fetch the program with ACL enforcement.
*
* The requester is derived from an optional signed `auth` envelope only —
* never from a caller-supplied address string. Public programs stay readable
* anonymously; restricted/owner programs require a fresh, valid signature
* proving control of the address (see {@link resolveReadRequester}). Missing
* or invalid auth resolves to anonymous, so non-public reads fail closed with
* the same 403 as an unmatched ACL.
*
* Returns either:
* - { program } on success, or
* - { error: RPCResponse } when the address is invalid, the program is missing
Expand All @@ -101,7 +109,7 @@ export async function getStorageProgramRepository() {
*/
export async function getAccessibleProgram(
storageAddress: unknown,
requesterAddress?: string,
auth?: unknown,
): Promise<{ program?: GCRStorageProgram; error?: RPCResponse }> {
if (typeof storageAddress !== "string" || storageAddress.length === 0) {
return {
Expand All @@ -126,6 +134,14 @@ export async function getAccessibleProgram(
return { error: rpcNull() }
}

// Bind the ACL check to a verified signature over this storageAddress;
// undefined (anonymous) when auth is absent/invalid so non-public reads
// fail closed.
const requesterAddress = await resolveReadRequester(
readAuthScope.program(storageAddress),
auth,
)

const hasReadAccess = GCRStorageProgramRoutines.checkReadPermission(
program,
requesterAddress,
Expand Down Expand Up @@ -208,7 +224,7 @@ export function requireJsonObject(
export interface FieldReadInput {
storageAddress?: unknown
field?: unknown
requesterAddress?: unknown
auth?: unknown
}

/**
Expand All @@ -227,14 +243,14 @@ export interface FieldReadContext {
*
* The envelope is identical across these handlers:
* 1. validate `field` arg (non-empty string) -> 400 / INVALID_REQUEST
* 2. coerce `requesterAddress` to string|undefined
* 3. resolve the program with ACL enforcement (404/403/null already
* handled inside getAccessibleProgram)
* 4. require JSON object data -> 400 / INVALID_FIELD_TYPE
* 5. confirm the requested field exists on the object -> 404 /
* 2. resolve the program with ACL enforcement (404/403/null already
* handled inside getAccessibleProgram; the requester is derived from
* the optional signed `auth` envelope, not a plain address string)
* 3. require JSON object data -> 400 / INVALID_FIELD_TYPE
* 4. confirm the requested field exists on the object -> 404 /
* FIELD_NOT_FOUND
* 6. delegate the response shape to the reducer
* 7. catch + log + 500 / INTERNAL_ERROR
* 5. delegate the response shape to the reducer
* 6. catch + log + 500 / INTERNAL_ERROR
*
* The reducer is the only thing that varies between handlers, so we keep
* it tiny: it receives the resolved {field, value, program} and returns
Expand All @@ -256,15 +272,9 @@ export function withFieldRead(
}
const field = data.field

const requesterAddress =
typeof data?.requesterAddress === "string" &&
data.requesterAddress.length > 0
? data.requesterAddress
: undefined

const result = await getAccessibleProgram(
data?.storageAddress,
requesterAddress,
data?.auth,
)
if (result.error) return result.error

Expand Down
Loading