fix(security): harden CodeQL secret ref validation

Remediate current-profile CodeQL findings for file SecretRef id validation and release workflow job permissions. Includes changelog credit. Thanks @vincentkoc.
This commit is contained in:
Vincent Koc
2026-04-27 13:53:27 -07:00
committed by GitHub
parent f2ba8ca927
commit bd51f82efa
8 changed files with 91 additions and 5 deletions

View File

@@ -55,6 +55,7 @@ jobs:
# WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT.
runs-on: ubuntu-24.04
environment: docker-release
permissions: {}
steps:
- name: Approve Docker backfill
env:

View File

@@ -527,6 +527,7 @@ jobs:
- qa_live_telegram_release_checks
if: always()
runs-on: ubuntu-24.04
permissions: {}
timeout-minutes: 5
steps:
- name: Verify release check results

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Plugins/startup: parse strict JSON plugin manifests with native JSON first and keep JSON5 as the compatibility fallback, reducing manifest registry CPU during Gateway boot and CLI startup. Fixes #73011. Thanks @jasonftl.
- CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar.
- Gateway/runtime: keep dirty-tree status calls from rebuilding live `dist`, clear stale task and restart state across in-process restarts, retry transient Discord lazy imports, and let channel startup continue after slow model warmup so browser, Discord, and voice-call sidecars come online. Thanks @vincentkoc.
- Security/CodeQL: replace file SecretRef id gateway schema regex validation with segment-aligned predicates and set empty permissions on release summary/backfill jobs so the narrowed CodeQL profile stays clean. Thanks @vincentkoc.
- Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon.
- Sessions: apply search, activity filters, and limits before gateway row enrichment so bounded session lists avoid scanning discarded transcripts. Carries forward #72978. Thanks @yeager.
- Sessions: remove trajectory runtime and pointer sidecars when session maintenance prunes, caps, or disk-evicts their owning session, while preserving sidecars still referenced by live rows. Fixes #73000. Thanks @jared-rebel.

View File

@@ -2,8 +2,10 @@ import { Type } from "typebox";
import { ENV_SECRET_REF_ID_RE } from "../../../config/types.secrets.js";
import {
EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN,
FILE_SECRET_REF_ID_PATTERN,
FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN,
FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN,
SECRET_PROVIDER_ALIAS_PATTERN,
SINGLE_VALUE_FILE_REF_ID,
} from "../../../secrets/ref-contract.js";
import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js";
@@ -53,11 +55,24 @@ const EnvSecretRefSchema = Type.Object(
{ additionalProperties: false },
);
const FileSecretRefIdSchema = Type.Unsafe<string>({
type: "string",
anyOf: [
{ const: SINGLE_VALUE_FILE_REF_ID },
{
allOf: [
{ pattern: FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN },
{ not: { pattern: FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN } },
],
},
],
});
const FileSecretRefSchema = Type.Object(
{
source: Type.Literal("file"),
provider: SecretProviderAliasString,
id: Type.String({ pattern: FILE_SECRET_REF_ID_PATTERN.source }),
id: FileSecretRefIdSchema,
},
{ additionalProperties: false },
);

View File

@@ -4,7 +4,9 @@ import { validateConfigObjectRaw } from "../config/validation.js";
import { SecretRefSchema as GatewaySecretRefSchema } from "../gateway/protocol/schema/primitives.js";
import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js";
import {
INVALID_FILE_SECRET_REF_IDS,
INVALID_EXEC_SECRET_REF_IDS,
VALID_FILE_SECRET_REF_IDS,
VALID_EXEC_SECRET_REF_IDS,
} from "../test-utils/secret-ref-test-vectors.js";
import {
@@ -13,7 +15,7 @@ import {
TALK_TEST_PROVIDER_ID,
} from "../test-utils/talk-test-provider.js";
import { isSecretsApplyPlan } from "./plan.js";
import { isValidExecSecretRefId } from "./ref-contract.js";
import { isValidExecSecretRefId, isValidFileSecretRefId } from "./ref-contract.js";
import { materializePathTokens, parsePathPattern } from "./target-registry-pattern.js";
import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpers.js";
import { listSecretTargetRegistryEntries } from "./target-registry.js";
@@ -39,6 +41,21 @@ describe("exec SecretRef id parity", () => {
return result.ok;
}
function configAcceptsFileRef(id: string): boolean {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
return result.ok;
}
function planAcceptsExecRef(id: string): boolean {
return isSecretsApplyPlan({
version: 1,
@@ -57,6 +74,17 @@ describe("exec SecretRef id parity", () => {
});
}
for (const id of [...VALID_FILE_SECRET_REF_IDS, ...INVALID_FILE_SECRET_REF_IDS]) {
it(`keeps config/gateway/plugin parity for file id "${id}"`, () => {
const expected = isValidFileSecretRefId(id);
expect(configAcceptsFileRef(id)).toBe(expected);
expect(validateGatewaySecretRef({ source: "file", provider: "default", id })).toBe(expected);
expect(
pluginSdkSecretInput.safeParse({ source: "file", provider: "default", id }).success,
).toBe(expected);
});
}
for (const id of [...VALID_EXEC_SECRET_REF_IDS, ...INVALID_EXEC_SECRET_REF_IDS]) {
it(`keeps config/plan/gateway/plugin parity for exec id "${id}"`, () => {
const expected = isValidExecSecretRefId(id);

View File

@@ -1,9 +1,29 @@
import { describe, expect, it } from "vitest";
import {
INVALID_FILE_SECRET_REF_IDS,
INVALID_EXEC_SECRET_REF_IDS,
VALID_FILE_SECRET_REF_IDS,
VALID_EXEC_SECRET_REF_IDS,
} from "../test-utils/secret-ref-test-vectors.js";
import { isValidExecSecretRefId, validateExecSecretRefId } from "./ref-contract.js";
import {
isValidExecSecretRefId,
isValidFileSecretRefId,
validateExecSecretRefId,
} from "./ref-contract.js";
describe("file secret ref id validation", () => {
it("accepts valid file secret ref ids", () => {
for (const id of VALID_FILE_SECRET_REF_IDS) {
expect(isValidFileSecretRefId(id), `expected valid id: ${id}`).toBe(true);
}
});
it("rejects invalid file secret ref ids", () => {
for (const id of INVALID_FILE_SECRET_REF_IDS) {
expect(isValidFileSecretRefId(id), `expected invalid id: ${id}`).toBe(false);
}
});
});
describe("exec secret ref id validation", () => {
it("accepts valid exec secret ref ids", () => {

View File

@@ -9,7 +9,8 @@ export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
export const SINGLE_VALUE_FILE_REF_ID = "value";
export const FILE_SECRET_REF_ID_PATTERN = /^(?:value|\/(?:[^~]|~0|~1)*(?:\/(?:[^~]|~0|~1)*)*)$/;
export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/";
export const FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN = "~(?:[^01]|$)";
export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN =
"^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$";

View File

@@ -1,3 +1,22 @@
export const VALID_FILE_SECRET_REF_IDS = [
"value",
"/",
"//",
"/providers/openai/apiKey",
"/providers//apiKey",
"/~0/~1",
`//${"/".repeat(256)}`,
] as const;
export const INVALID_FILE_SECRET_REF_IDS = [
"",
"providers/openai/apiKey",
"value/extra",
"/providers/openai/apiKey~",
"/providers/openai/apiKey~2",
"/providers/openai/~",
] as const;
export const VALID_EXEC_SECRET_REF_IDS = [
"vault/openai/api-key",
"vault:secret/mykey",