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
7 changes: 7 additions & 0 deletions .changeset/tasty-baboons-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@layerfig/config": patch
---

Fix: escape line breaks and special characters in slot values

- Safely escape \, ", \n, \r, and \t before insertion to avoid misparse/misrender issues.
25 changes: 25 additions & 0 deletions packages/config/src/config-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,31 @@ describe.each(builders)(
});

describe("slots", () => {
it("should handle multiline values", () => {
const pemKeyMock = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49
AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw
tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag==
-----END EC PRIVATE KEY-----`;

const config = new ConfigBuilder({
validate: (finalConfig) => finalConfig,
runtimeEnv: {
PEM_KEY: pemKeyMock,
},
})
.addSource(
new ObjectSource({
multiline: "${PEM_KEY}",
}),
)
.build();

expect(config).toEqual({
multiline: pemKeyMock,
});
});

it("should replace single slots", () => {
const schema = z.object({
port: z.coerce.number().int().positive(),
Expand Down
20 changes: 14 additions & 6 deletions packages/config/src/sources/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
UnknownArray,
UnknownRecord,
} from "../types";
import { escapeBreakLine } from "../utils/escape-break-line";
import { extractSlotsFromExpression, hasSlot, type Slot } from "../utils/slot";

const UNDEFINED_MARKER = "___UNDEFINED_MARKER___" as const;
Expand Down Expand Up @@ -34,7 +35,11 @@ export abstract class Source<T = Record<string, unknown>> {
options.slotPrefix,
);

let updatedContentString = options.contentString;
/**
* At this moment it does not matter what parser the user had defined,
* we're in the JS/JSON land.
*/
let updatedContentString = JSON.stringify(initialObject);

for (const slot of slots) {
let envVarValue: RuntimeEnvValue;
Expand All @@ -45,7 +50,7 @@ export abstract class Source<T = Record<string, unknown>> {
}

if (reference.type === "self_reference") {
const partialObj = options.transform(updatedContentString);
const partialObj = JSON.parse(updatedContentString);

envVarValue = get(
partialObj,
Expand All @@ -63,16 +68,19 @@ export abstract class Source<T = Record<string, unknown>> {
envVarValue = slot.fallbackValue;
}

updatedContentString = updatedContentString.replaceAll(
slot.slotMatch,
const valueToInsert =
envVarValue !== null && envVarValue !== undefined
? String(envVarValue)
: UNDEFINED_MARKER,
: UNDEFINED_MARKER;

updatedContentString = updatedContentString.replaceAll(
slot.slotMatch,
escapeBreakLine(valueToInsert),
);
}

const partialConfig = this.#cleanUndefinedMarkers(
options.transform(updatedContentString),
JSON.parse(updatedContentString),
);

return partialConfig;
Expand Down
74 changes: 74 additions & 0 deletions packages/config/src/utils/escape-break-line.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { escapeBreakLine } from "./escape-break-line";

describe("escapeBreakLine", () => {
it("should escape newlines", () => {
const input = "line1\nline2\nline3";
const expected = "line1\\nline2\\nline3";
expect(escapeBreakLine(input)).toBe(expected);
});

it("should escape double quotes", () => {
const input = 'He said "Hello World"';
const expected = 'He said \\"Hello World\\"';
expect(escapeBreakLine(input)).toBe(expected);
});

it("should escape backslashes", () => {
const input = "path\\to\\file";
const expected = "path\\\\to\\\\file";
expect(escapeBreakLine(input)).toBe(expected);
});

it("should escape carriage returns", () => {
const input = "line1\rline2";
const expected = "line1\\rline2";
expect(escapeBreakLine(input)).toBe(expected);
});

it("should escape tabs", () => {
const input = "col1\tcol2\tcol3";
const expected = "col1\\tcol2\\tcol3";
expect(escapeBreakLine(input)).toBe(expected);
});

it("should handle empty string", () => {
expect(escapeBreakLine("")).toBe("");
});

it("should handle string with no special characters", () => {
const input = "simple string";
expect(escapeBreakLine(input)).toBe(input);
});

it("should escape multiple special characters in correct order", () => {
const input = 'test\\with"quotes\nand\ttabs';
const expected = 'test\\\\with\\"quotes\\nand\\ttabs';
expect(escapeBreakLine(input)).toBe(expected);
});

it("should handle PEM key format", () => {
const pemKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49
AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw
tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag==
-----END EC PRIVATE KEY-----`;

const escaped = escapeBreakLine(pemKey);

// Should not contain actual newlines
expect(escaped).not.toContain("\n");
// Should contain escaped newlines
expect(escaped).toContain("\\n");
// Should start and end correctly
expect(escaped).toMatch(/^-----BEGIN EC PRIVATE KEY-----\\n/);
expect(escaped).toMatch(/\\n-----END EC PRIVATE KEY-----$/);
});

it("should handle complex strings with all escape characters", () => {
const input = 'backslash: \\ quote: " newline: \n tab: \t carriage: \r';
const expected =
'backslash: \\\\ quote: \\" newline: \\n tab: \\t carriage: \\r';
expect(escapeBreakLine(input)).toBe(expected);
});
});
12 changes: 12 additions & 0 deletions packages/config/src/utils/escape-break-line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function escapeBreakLine<T = unknown>(value: T): T {
if (typeof value !== "string") {
return value;
}

return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t") as T;
}