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
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
76 changes: 76 additions & 0 deletions tests/storageprogram/gcrroutine-readauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, jest, beforeAll } from "@jest/globals"

// `gcr_routine` storage reads must ACL-filter on the VERIFIED `sender` the RPC
// layer passed in — never a caller-supplied `params` value. Trivial stubs for
// the dispatcher's unrelated import chain; the storage routines are spied so we
// can assert exactly which requester reached the SQL filter.
jest.mock("@/utilities/logger", () => ({
__esModule: true,
default: { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() },
}))
jest.mock("@kynesyslabs/demosdk/types", () => ({ __esModule: true }))
jest.mock("@/libs/network/server_rpc", () => ({ __esModule: true, emptyResponse: { result: 200, response: null, require_reply: false, extra: null } }))
jest.mock("@/libs/blockchain/gcr/gcr_routines/identityManager", () => ({ __esModule: true, default: {} }))
jest.mock("@/libs/blockchain/gcr/gcr_routines/IncentiveManager", () => ({ __esModule: true, IncentiveManager: {} }))
jest.mock("@/libs/blockchain/gcr/gcr_routines/ensureGCRForUser", () => ({ __esModule: true, default: {} }))
jest.mock("@/features/incentive/referrals", () => ({ __esModule: true, Referrals: {} }))
jest.mock("@/libs/blockchain/gcr/gcr", () => ({ __esModule: true, default: {} }))
jest.mock("@/libs/identity/providers/nomisIdentityProvider", () => ({ __esModule: true, NomisIdentityProvider: {} }))
jest.mock("@/libs/identity/tools/humanpassport", () => ({ __esModule: true, default: {} }))
jest.mock("@/libs/identity/providers/ethosIdentityProvider", () => ({ __esModule: true, EthosIdentityProvider: {} }))
jest.mock("@/libs/communications/broadcastManager", () => ({ __esModule: true, BroadcastManager: {} }))
jest.mock("@/model/entities/GCRv2/GCR_StorageProgram", () => ({ __esModule: true, GCRStorageProgram: class {} }))
jest.mock("@/model/datasource", () => ({
__esModule: true,
default: { getInstance: async () => ({ getDataSource: () => ({ getRepository: () => ({}) }) }) },
}))

const listSpy = jest.fn(async () => [])
const searchSpy = jest.fn(async () => [])
jest.mock("@/libs/blockchain/gcr/gcr_routines/GCRStorageProgramRoutines", () => ({
__esModule: true,
GCRStorageProgramRoutines: {
getStorageProgramsByOwner: listSpy,
searchStorageProgramsByName: searchSpy,
},
}))

let manageGCRRoutines: typeof import("@/libs/network/manageGCRRoutines")["default"]
const VERIFIED = "aa".repeat(32)
const ATTACKER = "bb".repeat(32) // an allowlisted address the caller merely names

beforeAll(async () => {
;({ default: manageGCRRoutines } = await import("@/libs/network/manageGCRRoutines"))
})

describe("gcr_routine storage reads: ACL requester = verified sender", () => {
it("getStorageProgramsByOwner filters on the verified sender, not params", async () => {
await manageGCRRoutines(VERIFIED, {
method: "getStorageProgramsByOwner",
params: ["owner-x", ATTACKER, {}],
} as never)
// 3rd arg to the routine is the requesterAddress that hits the SQL ACL.
expect(listSpy).toHaveBeenCalled()
expect(listSpy.mock.calls[0][2]).toBe(VERIFIED)
expect(listSpy.mock.calls[0][2]).not.toBe(ATTACKER)
})

it("searchStoragePrograms filters on the verified sender, not params", async () => {
await manageGCRRoutines(VERIFIED, {
method: "searchStoragePrograms",
params: ["query", {}, ATTACKER],
} as never)
expect(searchSpy).toHaveBeenCalled()
const opts = searchSpy.mock.calls[0][2] as { requesterAddress?: string }
expect(opts.requesterAddress).toBe(VERIFIED)
expect(opts.requesterAddress).not.toBe(ATTACKER)
})

it("an anonymous (empty) sender reads as undefined even if params name an address", async () => {
await manageGCRRoutines("", {
method: "getStorageProgramsByOwner",
params: ["owner-x", ATTACKER, {}],
} as never)
expect(listSpy.mock.calls.at(-1)?.[2]).toBeUndefined()
})
})
67 changes: 67 additions & 0 deletions tests/storageprogram/readauth-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, jest } from "@jest/globals"

// The two read surfaces #970 did not cover — the HTTP route layer and the
// `gcr_routine` dispatcher — must derive the ACL requester from a VERIFIED
// identity, never a caller-supplied value. These lock that in.
//
// Mock only the heavy/side-effectful imports so the modules load without a DB
// or SDK. authContext is left REAL: the whole point is that the HTTP handler
// reads the middleware-verified context, so the test drives that real context.
jest.mock("@/utilities/logger", () => ({
__esModule: true,
default: { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() },
}))
jest.mock("@kynesyslabs/demosdk", () => ({ __esModule: true, types: {}, storage: {} }))
jest.mock("@/model/datasource", () => ({ __esModule: true, default: { getInstance: jest.fn() } }))
jest.mock("@/libs/network/bunServer", () => ({
__esModule: true,
jsonResponse: (body: unknown, status = 200) => ({ body, status }),
}))

let getRequesterAddress: typeof import("@/features/storageprogram/routes")["getRequesterAddress"]
let setAuthContext: typeof import("@/libs/network/authContext")["setAuthContext"]

const KEY = "aa".repeat(32) // raw public-key hex — the ACL address form

beforeAll(async () => {
;({ getRequesterAddress } = await import("@/features/storageprogram/routes"))
;({ setAuthContext } = await import("@/libs/network/authContext"))
})

function reqNamingAllowed(): Request {
// A caller that NAMES an allowlisted address in the raw `identity` header,
// with no signature — the exact bypass attempt.
return new Request("http://node/storage-program/stor-" + "0".repeat(40), {
headers: { identity: `ed25519:${KEY}` },
})
}

describe("HTTP read surface: getRequesterAddress", () => {
it("ignores the raw identity header — an unsigned caller is anonymous", () => {
// No auth context set (middleware never verified a signature), so even
// though the header names an allowed address, the requester is anonymous.
expect(getRequesterAddress(reqNamingAllowed())).toBeUndefined()
})

it("returns the requester only from the middleware-verified context", () => {
const req = reqNamingAllowed()
setAuthContext(req, {
verified: true,
identity: `ed25519:${KEY}`,
publicKey: KEY,
algorithm: "ed25519",
})
expect(getRequesterAddress(req)).toBe(KEY)
})

it("stays anonymous when the context carries no public key", () => {
const req = reqNamingAllowed()
setAuthContext(req, {
verified: false,
identity: null,
publicKey: null,
algorithm: null,
})
expect(getRequesterAddress(req)).toBeUndefined()
})
})
41 changes: 37 additions & 4 deletions tests/storageprogram/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"
import { readFileSync } from "fs"
import path from "path"
import { setAuthContext } from "@/libs/network/authContext"

// Mock logger
jest.mock("@/utilities/logger", () => ({
Expand Down Expand Up @@ -119,22 +120,37 @@ beforeEach(() => {
function makeRequest(
urlPath: string,
headers: Record<string, string> = {},
authenticatedAs?: string,
): Request {
return new Request(`http://localhost:53550${urlPath}`, {
const req = new Request(`http://localhost:53550${urlPath}`, {
headers: new Headers(headers),
})
if (authenticatedAs) {
// Simulate the auth middleware after a VERIFIED signature: only then is
// the verified key placed in the request's auth context. A raw
// `identity` header on its own sets nothing and grants nothing — that
// was the read-ACL bypass.
setAuthContext(req, {
verified: true,
identity: `ed25519:${authenticatedAs}`,
publicKey: authenticatedAs,
algorithm: "ed25519",
})
}
return req
}

async function callRoute(
pattern: string,
urlPath: string,
headers: Record<string, string> = {},
authenticatedAs?: string,
): Promise<{ status: number; body: any }> {
const handler = capturedRoutes.get(pattern)
if (!handler) {
throw new Error(`Route not found: ${pattern}. Available: ${Array.from(capturedRoutes.keys()).join(", ")}`)
}
const response = await handler(makeRequest(urlPath, headers))
const response = await handler(makeRequest(urlPath, headers, authenticatedAs))
const body = await response.json()
return { status: response.status, body }
}
Expand Down Expand Up @@ -214,19 +230,36 @@ describe("StorageProgram HTTP Routes", () => {
expect(body.errorCode).toBe("PERMISSION_DENIED")
})

it("200: owner-mode program with owner identity", async () => {
it("200: owner-mode program with a verified owner", async () => {
mockRepository.findOneBy.mockResolvedValue(
entityFixtures.owner_json_program,
)

const { status, body } = await callRoute(
"/storage-program/*",
"/storage-program/stor-owner-only-789",
{ identity: "owner_addr_1" },
{},
"owner_addr_1", // verified by the middleware, not a raw header
)
expect(status).toBe(200)
expect(body.success).toBe(true)
})

it("403: owner-mode named via an unverified raw identity header (bypass closed)", async () => {
mockRepository.findOneBy.mockResolvedValue(
entityFixtures.owner_json_program,
)

// The old bypass: name the owner in the `identity` header, no
// signature. With no verified auth context it is anonymous → 403.
const { status, body } = await callRoute(
"/storage-program/*",
"/storage-program/stor-owner-only-789",
{ identity: "owner_addr_1" },
)
expect(status).toBe(403)
expect(body.errorCode).toBe("PERMISSION_DENIED")
})
})

// =========================================================================
Expand Down