{"record":{"id":"55a7ad72cba66de1","repo":"tinyhumansai/openhuman","slug":"invalid-draft-id-id","errorCode":null,"errorMessage":"invalid draft id: {id:?}","messagePattern":"invalid draft id: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/openhuman/flows/draft_store.rs","lineNumber":42,"sourceCode":"fn drafts_dir(config: &Config) -> PathBuf {\n    config.workspace_dir.join(\"flows\").join(\"drafts\")\n}\n\n/// Whether `id` is a safe draft-file stem — guards `get`/`update`/`delete`\n/// against path traversal (`..`, separators) since the id reaches the\n/// filesystem. Server-minted ids are UUIDs; this only accepts that shape.\nfn is_safe_draft_id(id: &str) -> bool {\n    !id.is_empty()\n        && id.len() <= 64\n        && id\n            .chars()\n            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')\n}\n\n/// The on-disk path for draft `id` (validated).\nfn draft_path(config: &Config, id: &str) -> Result<PathBuf> {\n    if !is_safe_draft_id(id) {\n        bail!(\"invalid draft id: {id:?}\");\n    }\n    Ok(drafts_dir(config).join(format!(\"{id}.json\")))\n}\n\n/// Creates a new draft, writes it to disk, and returns it.\npub fn create_draft(\n    config: &Config,\n    flow_id: Option<String>,\n    name: String,\n    graph: Value,\n    origin: DraftOrigin,\n) -> Result<FlowDraft> {\n    let now = Utc::now().to_rfc3339();\n    let draft = FlowDraft {\n        id: Uuid::new_v4().to_string(),\n        flow_id,\n        name,\n        graph,","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/src/openhuman/flows/draft_store.rs#L24-L60","documentation":"draft_path validates the id before joining it into a path under the drafts directory: non-empty, at most 64 chars, and only ASCII alphanumeric, '-' and '_'. Anything else — dots, slashes, spaces, unicode — bails. Server-minted draft ids are UUIDs and always pass; the guard exists because an unvalidated join would allow path traversal ('../') out of the drafts dir.","triggerScenarios":"A client (or hostile/fuzzed caller) supplies a draft id like '../../config', 'my draft', 'a.b', or over 64 chars to any draft read/write/update/delete API that resolves a path; URL-decoded values like '..%2Fx'; ids minted client-side instead of using the one create_draft returned.","commonSituations":"Frontend generating its own ids; user-typed names fed where ids belong; stored ids surviving a format change; ids containing characters legal elsewhere (dots in filenames) but not here.","solutions":["Only use ids returned by create_draft; do not mint ids client-side","If you must pre-validate, mirror the rule: /^[A-Za-z0-9_-]{1,64}$/","Map user-typed names to server ids via the drafts list — never pass names as ids"],"exampleFix":"// before\nconst draft = await loadDraft(userTypedName); // '../../x' → bail\n\n// after\nconst { id } = await createDraft(...);\nconst draft = await loadDraft(id); // server-minted UUID","handlingStrategy":"type-guard","validationCode":"fn is_safe_draft_id(id: &str) -> bool {\n    !id.is_empty()\n        && id.len() <= 64\n        && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')\n}\n// reject before the call: if (!is_safe_draft_id(id)) { return Err(invalid_id); }","typeGuard":"const SAFE_DRAFT_ID = /^[A-Za-z0-9_-]{1,64}$/;\nfunction isSafeDraftId(id: string): boolean {\n  return SAFE_DRAFT_ID.test(id);\n}","tryCatchPattern":null,"preventionTips":["Treat draft ids as opaque server-minted tokens; never construct them from user input","Validate ids at the API boundary with the same character allowlist","Reject overlong or oddly-shaped ids early with a 400 rather than letting them reach the store"],"tags":["flows","draft","validation","path-traversal","security"],"backgroundTag":"invalid-identifier-format","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}