-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"The narrator wants to walk that down. The Mattermost server in question ships the Boards plugin prepackaged. Inside the prepackaged binary is `focalboard-v8.0.0`, the last cut of the codebase before the project rebranded to `mattermost-plugin-boards`. Both repos exist. The vulnerable file is the same file in each. The fix only landed in the rebranded repo, which is what CVE-2025-25279 covers.\n\n## Import is not in either PoC\n\nThe two public PoCs both produce the same primitive and neither one imports a board.\n\nThe first PoC, the one the scanner cited, is a French undergraduate red-team exercise. It is a bash script that hits the REST API directly. It logs in, creates a team, creates a board, creates a card block, and then POSTs an attachment block as a child of that card. The attachment block looks like this:\n\n```json\n{\n \"id\": \"a<27-char-random>\",\n \"type\": \"attachment\",\n \"boardId\": \"\",\n \"parentId\": \"\",\n \"fields\": {\n \"fileId\": \"../../../../../../../etc/passwd\"\n }\n}\n```\n\nThat single POST is the entire write side of the attack. The board now contains a block whose `fields.fileId` is a relative path. The block was accepted because the field is typed as `map[string]interface{}` and the only thing the server checked about the value at write time was that the request parsed as JSON.\n\nThe script then calls `POST /api/v2/boards//duplicate?asTemplate=false`. The duplicate endpoint walks every block on the source board, and for each attachment or image block, copies the referenced file from its source location to a new path under the new board. The copy uses the value of `fields.fileId` as the source filename. The server reads `/etc/passwd` off disk, writes it to a fresh path under the boards data directory, and stamps the new path into the duplicated block.\n\nThe script then calls `GET /api/v2/boards//archive/export`. The archive walker streams the file at the duplicated block's path into the ZIP. The ZIP is downloaded. `unzip -p` against the entry corresponding to the duplicated attachment yields `root:x:0:0:root:/root:/bin/bash`, and so on.\n\nThe second PoC, by an independent researcher, varies the encoding of the traversal string and skips the duplicate step on certain server versions where the export reader is itself willing to dereference the source block's `fileId` directly. Either path produces an arbitrary-file-read primitive against any path the server process can stat. There is no archive upload anywhere in either chain.\n\nThe CVE-2025-25279 description writes the bug as if the attack vector were a malicious archive being imported. It is not. The attack vector is a malicious *block* being inserted through the normal block-write API. Import is one of several call sites that eventually consume the block; it is not the source of the untrusted data. The data was untrusted the moment it was POSTed.\n\n## fileId is a string\n\nThe Block model in `server/model/block.go` carries the file reference inside a generic fields map:\n\n```go\ntype Block struct {\n ...\n Fields map[string]interface{} `json:\"fields\"`\n ...\n}\n```\n\nWhen the duplicate flow needs to find the file for a block, it pulls the value out with a type assertion:\n\n```go\nfileID, isOk := block.Fields[\"fileId\"].(string)\nif !isOk {\n fileID, isOk = block.Fields[\"attachmentId\"].(string)\n if !isOk {\n continue\n }\n}\n```\n\nThe `.(string)` assertion is a syntactic check, not a semantic one. It rejects integers and nulls. It does not reject `../../../../../../../etc/passwd`. The variable name `fileID` is a hint to the reader, nothing more. The Go compiler does not know what an ID looks like. Neither did the function it was passed to.\n\nIn the pre-patch tree, the path that `fileID` flowed into is `App.GetFilePath`. That function was the resolver for \"given a board context and a file reference, what disk path do I read from.\"\n\n```go\nfunc (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) {\n fileInfo, err := a.GetFileInfo(fileName)\n if err != nil && !model.IsErrNotFound(err) {\n return nil, \"\", err\n }\n\n var filePath string\n\n if fileInfo != nil && fileInfo.Path != \"\" && fileInfo.Path != emptyString {\n filePath = fileInfo.Path\n } else {\n filePath = filepath.Join(teamID, rootID, fileName)\n }\n\n return fileInfo, filePath, nil\n}\n```\n\nThe narrator wants you to look at the `else` branch. The fast path tries the `FileInfo` table first; that table is keyed by ID and would refuse to return a row for `../../../../../../../etc/passwd` because the lookup parses an ID out of the prefix and `..` does not match. That lookup returns \"not found\" cleanly, the function continues, and the fallback computes the path with `filepath.Join`.\n\n`filepath.Join` cleans its arguments. It collapses `..` segments against the preceding components. Joining `teamID + boardID + ../../../../../../../etc/passwd` collapses to `/etc/passwd`. The two leading components are eaten by the seven `..` segments. The function returns `/etc/passwd` as the file path.\n\n`GetFilePath` is then consumed by `CopyCardFiles`:\n\n```go\nfileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileID)\nif err != nil {\n return nil, fmt.Errorf(\"cannot fetch destination board %s for CopyCardFiles: %w\", sourceBoardID, err)\n}\ndestinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename)\n...\nif err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {\n a.logger.Error(...)\n}\n```\n\nThe copy is unconditional. Source path is whatever fell out of the join. There is no check that source resolves under any expected root. There is no check that source is one of the values originally written by `SaveFile`. There is no check that the file at source has a `FileInfo` row pointing back at it. The boundary the function defends is \"did the read succeed,\" not \"was the read allowed.\"\n\n## Duplicate is when the server reads what you wrote\n\nThe narrator pauses on this because it is the part the description occludes.\n\nIn the import-export framing CVE-2025-25279 uses, the threat actor's leverage is a crafted artifact crossing a parser boundary. The defender's mental model becomes \"validate archives more strictly.\" That model is wrong here. The threat actor's leverage is *the block they wrote through the normal API*. They are using a feature of the application: cards have attachments, attachments have file IDs, boards can be duplicated. The server is performing the duplicate operation as documented. The bug is that the server, while performing a documented operation on an attacker-controlled block, dereferences a string the attacker wrote as a filesystem path.\n\nThis is the shape of the bug. The block-write endpoint accepts a string. The duplicate endpoint reads that string as a path. Between those two endpoints, no one converts the string from \"thing the user typed\" into \"thing the server is willing to read.\" The duplicate endpoint trusts that whatever ended up in `fields.fileId` was put there by `SaveFile`. `SaveFile` assigns IDs of the form `7<26-char-mattermost-id><.ext>`. The duplicate endpoint never checks the shape against that contract. It just calls `filepath.Join` and reads.\n\nExport then completes the read by streaming the copied file into a ZIP. Export is doing its job. Duplicate did the dangerous part.\n\n## Three rings the patch added\n\nThe post-patch tree adds three independent rings, and the layering tells you what the maintainers concluded the bug was.\n\nRing one is at the field level. `model.Block.IsValid` now calls `ValidateFileId` on `fields.fileId` and `fields.attachmentId` before any block can be persisted:\n\n```go\nif fileID, ok := b.Fields[BlockFieldFileId].(string); ok {\n if err = ValidateFileId(fileID); err != nil {\n return err\n }\n}\nif attachmentId, ok := b.Fields[BlockFieldAttachmentId].(string); ok {\n if err = ValidateFileId(attachmentId); err != nil {\n return err\n }\n}\n```\n\n`ValidateFileId` requires length at least 27, then checks that characters 1 through 26 either parse as a Mattermost ID or, for legacy data, that the string is a 36-character UUID with hyphens at positions 8, 13, 18, 23 and only hex characters elsewhere. `../../../../../../../etc/passwd` is 31 characters, character 1 is `.`, no Mattermost ID parse succeeds, no UUID structure matches. The validation rejects it.\n\nThe same check is also applied through `ValidateBlockPatch` and through a new helper `validateFileRefsInFields` that the app layer wires into `InsertBlock`, `InsertBlocksAndNotify`, `PatchBlock`, `PatchBlockAndNotify`, `PatchBlocksAndNotify`, and `CreateBoardsAndBlocks`. Every path that lets a string become a `fields.fileId` now stops the string at the model boundary.\n\nRing two is at the resolver level. The new `CopyCardFiles` calls `model.ValidateFileId(fileID)` again before doing anything else with the value. This is belt-and-suspenders for the case where stored data predates the fix or where a block snuck through some other path.\n\nRing three is structural. The destination path layout changed. `getDestinationFilePath` now returns `boards/YYYYMMDD/{filename}` for non-template files, and a sibling `validateFileOwnershipForBlockWrite` checks that any `fields.fileId` written by a block resolves under a path the boards plugin actually owns: either the template prefix, the new dated prefix, or a legacy `teamID/boardID/...` prefix that is verified by scanning blocks. There is also a `validatePathComponent` regex `^[a-zA-Z0-9._-]+$` applied per-component. A string with `..` segments fails the regex on the second component.\n\nYou can read those three rings as concentric: validate the field at write, validate the field at resolve, validate the resolved path is inside the plugin's directory. Any one of them defeats the original PoC. Stacking three of them is an admission that the surface used to be flat and that the fix is not just \"add a check\" but \"introduce a layer where there used to be none.\"\n\n## What the read primitive actually reads\n\nThe exploit is presented as `cat /etc/passwd`, which is the conventional flag for \"I have arbitrary file read.\" The narrator wants to read the primitive more carefully than the convention reads it.\n\nThe server process running the Mattermost binary is a long-lived daemon. The user it runs as is, in the Omnibus and Docker default configurations, a dedicated `mattermost` user. That user can read its own config files. Mattermost's config can include the database connection string in plaintext. It can include the SMTP password. It can include the SSO client secret. It can include the encryption key for at-rest data. The data directory holds every uploaded file ever, regardless of which channel posted it, regardless of which team owns it. The session token salt is a file. The license file is a file. Plugin manifests are files.\n\nThe `..` chain in the PoC is sized for `/etc/passwd` because seven traversals is enough to climb out of the dated boards directory and reach the filesystem root in most installs. The same primitive with a different number of traversals reaches the config directory, the data directory, the plugin directory. The PoC author wrote `etc/passwd` because that is the universally-recognized poster file for \"arbitrary read.\" That is not the bound on what the primitive returns. The primitive returns any file the daemon user can stat.\n\nThe score the scanner produced (CVSSv3 9.9, attack vector network, privileges required low, scope changed) reflects the bound. The scope-change rating is the part the scanner is stating loudly: a string in a board block, written by a user with permission to write to one board, reads files outside of any boards-plugin context. The plugin's authorization domain stops mattering as soon as the path leaves the plugin's directory.\n\n## Name is the only type\n\nThe narrator wants to give the bug its tightest possible name. Name Is The Only Type: a field stored as a generic map whose conventional name implies a constrained shape, where the only enforcement of that shape is the variable's identifier.\n\nThe `Fields` map has no schema. The codebase compensates by giving fields conventional names: `fileId`, `attachmentId`, `icon`, `contentOrder`. The names are documentation. Nothing else enforces what the names imply.\n\nWhen `fileId` first entered the codebase, a file ID was generated by `SaveFile` and stored back into the block. The server controlled both the producer and the consumer of `fileId`, so its shape was implicit. The producer happened to emit a 27-character string with a one-character prefix. The consumer happened to accept any string. Because the only producer was trusted, the gap did not matter.\n\nThe block-write API broke that arrangement. Once the API let any authenticated user PUT any string into `fields.fileId`, the consumer's \"any string\" became the bug. The producer was no longer the only writer. The contract that had been implicit between two pieces of server code was now implicit between server code and anyone with a session.\n\nThe patch retroactively types the field. `ValidateFileId` is the type definition: a `fileId` is either a 27-character string whose tail is a Mattermost ID or a 36-character UUID with the standard hyphen layout. The function exists because there was previously no place that defined what a `fileId` was. It was a string, named like an ID, treated like a path.\n\nThe structural change in ring three is the same observation in a different register. By giving the plugin's file storage a known prefix (`boards/YYYYMMDD/...`) and refusing to read from anywhere else, the maintainers gave the resolver a domain it can validate against. Before the patch, the resolver had no domain. It would join whatever it was given to whatever it was given and read whatever resulted. The resolver's input was a string named `fileName` and its output was a path. There was no point in the function where \"this is a path I am willing to read\" was distinguishable from \"this is a path that exists.\"\n\nThat is the part the reader has to do themselves. The CVE-2025-25279 description names import and export. The PoC cites duplicate. The pattern in the code is older than either. Anywhere a `map[string]interface{}` field whose name implies an identifier is consumed as a path, the bug is sitting and waiting for an API to expose write access to that field. The duplicate endpoint exposed it for `fileId` in 2025. The same exposure for `attachmentId` exists in the same code, which is why ring one validates both. The fix is the same in either case because the bug is the same in either case.\n\nThe block-write API is the source. The path resolver is the sink. The transformation between them was the variable name.\n","closing_line":"Names are not validation. The patch is what made `fileId` mean what it had been called.","hook_md":"CVE-2025-25279's description names the mechanism: improper validation of board blocks during import and export. The student PoC the scanner surfaced opens with `curl /plugins/focalboard/api/v2/boards`, creates a card, attaches a block whose `fileId` field is the literal string `../../../../../../../etc/passwd`, calls `/duplicate`, and downloads the export ZIP. No import. No archive on the attack side. The only code in the chain that treated `fileId` as an ID was the variable name.","post_id":52,"slug":"mattermost-cve-2025-25279-fileid-was-never-an-id","title":"CVE-2025-25279: Mattermost's fileId Was Never An ID","type":"initial","unreadable_sentence":"The only code in the chain that treated fileId as an ID was the variable name."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahCMTwAKCRDeZjl4jgkQ JnB2AP0S7WT+S7rP1mPIMPEy9AWxyxPktLdJAFvK/z5u+BK/1AD+P9akZriR05Ur hpF36C3539tGu6pQVGRwuldk71UV4AU= =dj9G -----END PGP SIGNATURE-----