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
29 changes: 18 additions & 11 deletions open-sse/services/comboAgentMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,25 @@

// ── Context Caching Tag ─────────────────────────────────────────────────────

// Handles both actual newlines (U+000A) and literal \n sequences injected
// by combo.ts streaming around the <omniModel> tag (#531). Non-global so that
// .exec() and .test() stay stateless (a global regex carries lastIndex between
// Detection / extraction pattern. The newline runs combo.ts streaming wraps the
// tag with (#531) are irrelevant to *finding* the tag or capturing the model id,
// so they are intentionally NOT matched here: an unbounded `(?:\\n|\n|\r)*` prefix
// on this unanchored regex made `.test()` / `.exec()` run in O(n²) on inputs with
// many newlines (polynomial ReDoS — CodeQL js/polynomial-redos, #3870). Non-global
// so `.exec()` / `.test()` stay stateless (a global regex carries lastIndex between
// calls and would skip matches).
const CACHE_TAG_PATTERN = /(?:\\n|\n|\r)*<omniModel>([^<]+)<\/omniModel>(?:\\n|\n|\r)*/;

// Global variant for .replace() callers that must strip EVERY tag. A non-global
// regex only removes the first match, so a single message carrying more than one
// <omniModel> tag (e.g. an Open WebUI follow-up/title request that inlines the
// whole chat history) leaked the remaining tags to the provider — defeating the
// cache-session protection stripModelTags exists to enforce (#454).
const CACHE_TAG_PATTERN_GLOBAL = /(?:\\n|\n|\r)*<omniModel>([^<]+)<\/omniModel>(?:\\n|\n|\r)*/g;
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;

// Global variant for `.replace()` callers that must strip EVERY tag (a non-global
// regex only removes the first match, so a message carrying more than one
// <omniModel> tag — e.g. an Open WebUI follow-up/title request that inlines the
// whole chat history — leaked the remaining tags to the provider, defeating the
// cache-session protection stripModelTags enforces, #454). This variant still
// consumes the newline run wrapping the tag (combo.ts streaming, #531) so removal
// leaves no blank line, but the runs are BOUNDED ({0,16}) to keep the regex linear
// (no polynomial backtracking, #3870); 16 is far beyond any real streaming wrap.
const CACHE_TAG_PATTERN_GLOBAL =
/(?:\\n|\n|\r){0,16}<omniModel>([^<]+)<\/omniModel>(?:\\n|\n|\r){0,16}/g;
Comment on lines +54 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

⚠️ Potential Chat History Corruption / Word Merging Bug

By matching and stripping up to 16 newlines both before and after the <omniModel> tag, any text preceding and succeeding the tag will be merged together.

For example, in an inlined chat history (like the Open WebUI follow-up requests mentioned in the comments), you might have:

Assistant: Hello!
<omniModel>gpt-4</omniModel>

User: How are you?

With the current regex, the \n before and \n\n after the tag are both consumed and replaced with "", resulting in:

Assistant: Hello!User: How are you?

This merges the assistant's message with the user's message, corrupting the chat history structure sent to the provider.

Suggested Fix

To avoid merging words/lines while still cleaning up the tag and its surrounding blank lines, we should only consume the newline run on one side (e.g., the trailing side). Any leading newlines before the tag will either act as the single natural separator between the preceding and succeeding text, or (if the tag was at the very end of the message) will be cleanly stripped by the subsequent .trimEnd() call.

Suggested change
const CACHE_TAG_PATTERN_GLOBAL =
/(?:\\n|\n|\r){0,16}<omniModel>([^<]+)<\/omniModel>(?:\\n|\n|\r){0,16}/g;
const CACHE_TAG_PATTERN_GLOBAL =
/<omniModel>([^<]+)<\/omniModel>(?:\\n|\n|\r){0,16}/g;


/**
* Inject the model tag into the last assistant message (or append a new one).
Expand Down Expand Up @@ -178,7 +185,7 @@

// Context cache pinning is handled server-side in combo.ts via
// session_model_history. No client-side <omniModel> tag extraction needed.
pinnedModel = null;

Check warning on line 188 in open-sse/services/comboAgentMiddleware.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Review this redundant assignment: "pinnedModel" already holds the assigned value along all execution paths.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_OmniRoute&issues=AZ7QY2hnjvglO8hgOvGL&open=AZ7QY2hnjvglO8hgOvGL&pullRequest=3983

// 2. System message override
if (comboConfig.system_message && comboConfig.system_message.trim()) {
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/combo-omnimodel-tag-stripping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
stripModelTags,
injectModelTag,
extractPinnedModel,
} from "../../open-sse/services/comboAgentMiddleware.ts";

// Regression for the non-global CACHE_TAG_PATTERN bug: `.replace()` with a
Expand Down Expand Up @@ -56,3 +57,45 @@ describe("comboAgentMiddleware — <omniModel> tag stripping (non-global regex b
);
});
});

// Regression for CodeQL js/polynomial-redos (#3870): the unbounded `(?:\\n|\n|\r)*`
// prefix/suffix on the unanchored CACHE_TAG_PATTERN made `.test()` / `.replace()`
// run in O(n²) on inputs with many newlines. The fix drops the surrounding runs from
// the detection pattern and bounds them ({0,16}) in the global strip pattern.
describe("comboAgentMiddleware — ReDoS safety + newline-wrapped tags (#3870)", () => {
test("stripModelTags stays linear on a long run of newlines (no tag present)", () => {
const evil = "\n".repeat(50_000);
const start = process.hrtime.bigint();
const stripped = stripModelTags([{ role: "user", content: evil }]);
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
assert.equal(stripped[0].content, evil, "no tag → content returned unchanged");
assert.ok(elapsedMs < 1000, `regex must stay linear; took ${elapsedMs.toFixed(1)}ms`);
});

test("stripModelTags stays linear on many newlines before a never-closing tag", () => {
const evil = "\n".repeat(50_000) + "<omniModel" + "x".repeat(2000);
const start = process.hrtime.bigint();
stripModelTags([{ role: "user", content: evil }]);
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
assert.ok(elapsedMs < 1000, `regex must stay linear; took ${elapsedMs.toFixed(1)}ms`);
});

test("extractPinnedModel still finds a tag wrapped in newlines", () => {
const messages = [
{
role: "assistant",
content: "answer\n\n<omniModel>claude/claude-opus-4-8</omniModel>\n\n",
},
];
assert.equal(extractPinnedModel(messages), "claude/claude-opus-4-8");
});

test("stripModelTags removes the newline run wrapping a tag (no blank line left)", () => {
const out = String(
stripModelTags([{ role: "user", content: "before\n\n<omniModel>a/b</omniModel>\n\nafter" }])[0]
.content
);
assert.ok(!out.includes("<omniModel>"), "tag removed");
assert.ok(!out.includes("\n\n\n"), "no triple newline left from stripping");
});
Comment on lines +93 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

🧪 Update Regression Test to Prevent Word Merging

Let's update the regression test to assert that the paragraph break is preserved and words are not merged when stripping the tag.

  test("stripModelTags removes the newline run wrapping a tag (no blank line left)", () => {
    const out = String(
      stripModelTags([{ role: "user", content: "before\n\n<omniModel>a/b</omniModel>\n\nafter" }])[0]
        .content
    );
    assert.ok(!out.includes("<omniModel>"), "tag removed");
    assert.ok(!out.includes("\n\n\n"), "no triple newline left from stripping");
    assert.equal(out, "before\n\nafter", "should preserve the paragraph break and not merge words");
  });

});