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
137 changes: 137 additions & 0 deletions apps/console/src/lib/crd-option-sources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest"
import { graftOptionSources } from "./crd-option-sources.ts"

interface SchemaNode {
type?: string
properties?: Record<string, SchemaNode>
"x-cozystack-options"?: { source: string }
}

describe("graftOptionSources", () => {
it("grafts a source onto a nested field (applicationRef.kind)", () => {
const spec: SchemaNode = {
type: "object",
properties: {
applicationRef: {
type: "object",
properties: {
kind: { type: "string" },
name: { type: "string" },
},
},
},
}

const out = graftOptionSources(spec, {
"options.cozystack.io/source.applicationRef.kind": "appkind",
}) as SchemaNode

expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({
source: "appkind",
})
// Sibling left untouched.
expect(
out.properties?.applicationRef?.properties?.name?.["x-cozystack-options"],
).toBeUndefined()
})

it("grafts a source onto a top-level spec field (backupClassName)", () => {
const spec: SchemaNode = {
type: "object",
properties: { backupClassName: { type: "string" } },
}

const out = graftOptionSources(spec, {
"options.cozystack.io/source.backupClassName": "backupclass",
}) as SchemaNode

expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({
source: "backupclass",
})
})

it("applies every source annotation on a CRD (BackupJob shape)", () => {
const spec: SchemaNode = {
type: "object",
properties: {
applicationRef: { type: "object", properties: { kind: { type: "string" } } },
planRef: { type: "object", properties: { name: { type: "string" } } },
backupClassName: { type: "string" },
},
}

const out = graftOptionSources(spec, {
"controller-gen.kubebuilder.io/version": "v0.16.4",
"options.cozystack.io/source.applicationRef.kind": "appkind",
"options.cozystack.io/source.planRef.name": "plan",
"options.cozystack.io/source.backupClassName": "backupclass",
}) as SchemaNode

expect(out.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toEqual({
source: "appkind",
})
expect(out.properties?.planRef?.properties?.name?.["x-cozystack-options"]).toEqual({
source: "plan",
})
expect(out.properties?.backupClassName?.["x-cozystack-options"]).toEqual({
source: "backupclass",
})
})

it("ignores annotations without the option-source prefix", () => {
const spec: SchemaNode = {
type: "object",
properties: { backupClassName: { type: "string" } },
}

const out = graftOptionSources(spec, {
"controller-gen.kubebuilder.io/version": "v0.16.4",
"options.cozystack.io/other": "noise",
}) as SchemaNode

expect(out.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined()
})

it("is a no-op for a path that does not exist in the schema", () => {
const spec: SchemaNode = {
type: "object",
properties: { backupClassName: { type: "string" } },
}

expect(() =>
graftOptionSources(spec, {
"options.cozystack.io/source.missing.field": "appkind",
}),
).not.toThrow()
})

it("does not mutate the input schema, including nested nodes", () => {
const spec: SchemaNode = {
type: "object",
properties: {
backupClassName: { type: "string" },
applicationRef: { type: "object", properties: { kind: { type: "string" } } },
},
}

graftOptionSources(spec, {
"options.cozystack.io/source.backupClassName": "backupclass",
"options.cozystack.io/source.applicationRef.kind": "appkind",
})

// Both a top-level field and a nested one must be left untouched, so a
// future shallow/partial clone that corrupts deep nodes can't slip through.
expect(spec.properties?.backupClassName?.["x-cozystack-options"]).toBeUndefined()
expect(spec.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toBeUndefined()
})

it("returns the schema unchanged when there are no annotations", () => {
const spec: SchemaNode = {
type: "object",
properties: { backupClassName: { type: "string" } },
}

expect(graftOptionSources(spec, undefined)).toBe(spec)
expect(graftOptionSources(spec, {})).toEqual(spec)
})
})
56 changes: 56 additions & 0 deletions apps/console/src/lib/crd-option-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Graft the `x-cozystack-options` vendor keyword back onto a CRD's spec schema
* from its metadata annotations.
*
* The backups.cozystack.io CRDs cannot carry `x-cozystack-options` in their
* OpenAPI schema: apiextensions `JSONSchemaProps` is a closed struct that only
* preserves `x-kubernetes-*` extensions, and server-side apply rejects any
* other `x-` key outright. The field→source mapping the dropdowns need is
* therefore stored in CRD metadata annotations (which the apiserver does
* preserve) and reattached client-side here, so DynamicOptionsWidget — which
* reads `x-cozystack-options.source` off the schema node — keeps working.
*
* Annotation contract (emitted by kubebuilder markers on the Go types):
* options.cozystack.io/source.<dotted spec-relative path> = <option source>
* e.g. `options.cozystack.io/source.applicationRef.kind: appkind`.
*/

const SOURCE_ANNOTATION_PREFIX = "options.cozystack.io/source."

/**
* Return a copy of `specSchema` with `x-cozystack-options: { source }` set on
* every field named by a matching annotation. The input is not mutated. Paths
* that do not resolve in the schema are skipped silently.
*/
export function graftOptionSources(
specSchema: unknown,
annotations: Record<string, string> | undefined | null,
): unknown {
if (!specSchema || typeof specSchema !== "object" || !annotations) {
return specSchema
}

const cloned = structuredClone(specSchema)

for (const [key, source] of Object.entries(annotations)) {
if (!source || !key.startsWith(SOURCE_ANNOTATION_PREFIX)) continue
const path = key.slice(SOURCE_ANNOTATION_PREFIX.length).split(".")
applySource(cloned, path, source)
}

return cloned
}

function applySource(specSchema: unknown, path: string[], source: string): void {
let node = specSchema
for (const segment of path) {
const properties = (node as { properties?: Record<string, unknown> })?.properties
if (!properties || typeof properties !== "object" || !(segment in properties)) {
return
Comment on lines +47 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In JavaScript/TypeScript, does key in objinclude prototype-chain properties likeprotoandconstructor, and is Object.hasOwn(obj, key) the recommended safe check to prevent prototype-pollution-style key traversal?

💡 Result:

Yes, the in operator in JavaScript and TypeScript checks for the existence of a property both on the object itself and throughout its entire prototype chain [1][2]. Consequently, key in obj will return true for inherited properties such as proto, constructor, or any other properties defined on the object's prototypes [3][4][2]. Object.hasOwn(obj, key) is the recommended, modern, and safe way to check if a property is defined directly on an object (as an "own" property) without traversing its prototype chain [3][4][5]. Using Object.hasOwn() helps mitigate risks related to prototype pollution and avoids common pitfalls associated with older methods [4][6][5]: - It works correctly on objects created with Object.create(null) (which do not inherit from Object.prototype and therefore lack the hasOwnProperty method) [4][7]. - It is immune to issues where an object might have a shadowed or overridden hasOwnProperty property [4][7]. For codebases that need to support older environments, the traditional workaround is Object.prototype.hasOwnProperty.call(obj, key), but Object.hasOwn() is preferred wherever ES2022+ is supported [3][5][8]. When iterating over object keys, you should also prefer methods that only access own properties, such as Object.keys(), Object.values(), Object.entries(), or for...of loops, over the for...in loop, which also traverses the prototype chain [9][6].

Citations:


Avoid prototype-chain key traversal in annotation-path resolution

Line 48’s segment in properties matches inherited keys via the prototype chain (e.g. __proto__, constructor). That can cause the traversal to set node to a prototype object and then mutate it at lines 54-55. Switching to an own-property check prevents this.

Proposed fix
 function applySource(specSchema: unknown, path: string[], source: string): void {
   let node = specSchema
   for (const segment of path) {
     const properties = (node as { properties?: Record<string, unknown> })?.properties
-    if (!properties || typeof properties !== "object" || !(segment in properties)) {
+    if (
+      !properties ||
+      typeof properties !== "object" ||
+      segment === "__proto__" ||
+      segment === "prototype" ||
+      segment === "constructor" ||
+      !Object.hasOwn(properties, segment)
+    ) {
       return
     }
     node = properties[segment]
   }
   if (node && typeof node === "object") {
     ;(node as Record<string, unknown>)["x-cozystack-options"] = { source }
   }
 }

Add a regression test to ensure options.cozystack.io/source.__proto__ is ignored.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const properties = (node as { properties?: Record<string, unknown> })?.properties
if (!properties || typeof properties !== "object" || !(segment in properties)) {
return
function applySource(specSchema: unknown, path: string[], source: string): void {
let node = specSchema
for (const segment of path) {
const properties = (node as { properties?: Record<string, unknown> })?.properties
if (
!properties ||
typeof properties !== "object" ||
segment === "__proto__" ||
segment === "prototype" ||
segment === "constructor" ||
!Object.hasOwn(properties, segment)
) {
return
}
node = properties[segment]
}
if (node && typeof node === "object") {
;(node as Record<string, unknown>)["x-cozystack-options"] = { source }
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/crd-option-sources.ts` around lines 47 - 49, The current
check uses "segment in properties" which matches inherited prototype keys;
change it to an own-property check (e.g.
Object.prototype.hasOwnProperty.call(properties, segment) or Object.hasOwn) so
only direct keys are used when resolving the annotation path and reassigning
node (the variables: properties, segment, node in crd-option-sources.ts). Update
the traversal logic that sets/mutates node to only proceed when the key is an
own property, and add a regression test asserting that an annotation like
"options.cozystack.io/source.__proto__" is ignored (i.e. does not create or
mutate prototype properties).

}
node = properties[segment]
}
Comment on lines +46 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Using the in operator to traverse the schema path is vulnerable to Prototype Pollution. If an annotation key contains __proto__ or constructor, it can traverse to and mutate Object.prototype, affecting all objects in the application.

To prevent this, we should explicitly block sensitive keys like __proto__ and constructor, and use Object.prototype.hasOwnProperty.call instead of the in operator to ensure we only traverse own properties of the schema.

  for (const segment of path) {
    if (segment === "__proto__" || segment === "constructor") {
      return
    }
    const properties = (node as { properties?: Record<string, unknown> })?.properties
    if (
      !properties ||
      typeof properties !== "object" ||
      !Object.prototype.hasOwnProperty.call(properties, segment)
    ) {
      return
    }
    node = properties[segment]
  }

if (node && typeof node === "object") {
;(node as Record<string, unknown>)["x-cozystack-options"] = { source }
}
}
10 changes: 8 additions & 2 deletions apps/console/src/lib/use-crd-schema.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useK8sGet } from "@cozystack/k8s-client"
import { graftOptionSources } from "./crd-option-sources.ts"

interface CRDVersion {
name: string
storage?: boolean
schema?: {
openAPIV3Schema?: {
properties?: {
spec?: any
spec?: unknown
}
}
}
Expand All @@ -17,6 +18,7 @@ interface CRD {
kind: string
metadata: {
name: string
annotations?: Record<string, string>
}
spec: {
group: string
Expand All @@ -40,7 +42,11 @@ export function useCRDSchema(crdName: string) {

// Use the storage version (authoritative) or fall back to the first listed version
const version = crd?.spec?.versions?.find((v) => v.storage) ?? crd?.spec?.versions?.[0]
const schema = version?.schema?.openAPIV3Schema?.properties?.spec
const specSchema = version?.schema?.openAPIV3Schema?.properties?.spec

// Reattach the x-cozystack-options dropdown hints the apiserver strips from
// the CRD schema; they are carried in metadata annotations instead.
const schema = graftOptionSources(specSchema, crd?.metadata?.annotations)

return {
schema: schema ? JSON.stringify(schema) : null,
Expand Down
Loading