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.
CVE-2025-25279: Mattermost's fileId Was Never An ID
pattern
cve
proof of concept
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.
Import is not in either PoC
The two public PoCs both produce the same primitive and neither one imports a board.
The 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:
{
"id": "a<27-char-random>",
"type": "attachment",
"boardId": "<board-id>",
"parentId": "<card-id>",
"fields": {
"fileId": "../../../../../../../etc/passwd"
}
}That 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.
The script then calls POST /api/v2/boards/<board-id>/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.
The script then calls GET /api/v2/boards/<new-board-id>/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.
The 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.
The 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.
fileId is a string
The Block model in server/model/block.go carries the file reference inside a generic fields map:
type Block struct {
...
Fields map[string]interface{} `json:"fields"`
...
}When the duplicate flow needs to find the file for a block, it pulls the value out with a type assertion:
fileID, isOk := block.Fields["fileId"].(string)
if !isOk {
fileID, isOk = block.Fields["attachmentId"].(string)
if !isOk {
continue
}
}The .(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.
In 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."
func (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) {
fileInfo, err := a.GetFileInfo(fileName)
if err != nil && !model.IsErrNotFound(err) {
return nil, "", err
}
var filePath string
if fileInfo != nil && fileInfo.Path != "" && fileInfo.Path != emptyString {
filePath = fileInfo.Path
} else {
filePath = filepath.Join(teamID, rootID, fileName)
}
return fileInfo, filePath, nil
}The 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.
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.
GetFilePath is then consumed by CopyCardFiles:
fileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileID)
if err != nil {
return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
}
destinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename)
...
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
a.logger.Error(...)
}The 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."
Duplicate is when the server reads what you wrote
The narrator pauses on this because it is the part the description occludes.
In 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.
This 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.
Export then completes the read by streaming the copied file into a ZIP. Export is doing its job. Duplicate did the dangerous part.
Three rings the patch added
The post-patch tree adds three independent rings, and the layering tells you what the maintainers concluded the bug was.
Ring one is at the field level. model.Block.IsValid now calls ValidateFileId on fields.fileId and fields.attachmentId before any block can be persisted:
if fileID, ok := b.Fields[BlockFieldFileId].(string); ok {
if err = ValidateFileId(fileID); err != nil {
return err
}
}
if attachmentId, ok := b.Fields[BlockFieldAttachmentId].(string); ok {
if err = ValidateFileId(attachmentId); err != nil {
return err
}
}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.
The 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.
Ring 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.
Ring 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.
You 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."
What the read primitive actually reads
The 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.
The 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.
The .. 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.
The 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.
Name is the only type
The 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.
The 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.
When 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.
The 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.
The 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.
The 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."
That 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.
The block-write API is the source. The path resolver is the sink. The transformation between them was the variable name.
Names are not validation. The patch is what made fileId mean what it had been called.