Skip to content

Strict mode bypass in prisma-field-encryption allows non-ciphertext values to be returned in plaintext (fail-open behavior) #152

@fasrm

Description

@fasrm

Affected package
prisma-field-encryption
Tested with:

prisma-field-encryption (latest compatible)

Prisma 6.13.0

SQLite backend

Summary
When using the annotation /// @Encrypted?mode=strict, the library correctly throws an error when ciphertext is corrupted and decryption fails. However, if a value stored in the encrypted column does not match the expected ciphertext format, the extension does not attempt decryption and returns the raw value as plaintext.

This creates a strict-mode bypass condition where non-ciphertext values stored in encrypted fields are accepted and returned by the application without error, even though strict mode is enabled.

Impact
Strict mode implies fail-closed behavior. The current behavior is:

Valid ciphertext → decrypted successfully

Corrupted ciphertext → decryption error thrown (expected)

Non-ciphertext value → returned as plaintext (unexpected in strict mode)

If an attacker or system fault can write directly to the database, including through:

SQL injection

raw queries ($executeRaw)

migration errors

backup corruption

replication inconsistencies

insider access

They can inject arbitrary values into encrypted fields and the application will accept them as legitimate decrypted values.

Potential consequences:

Business logic bypass

Integrity violation of protected fields

Unexpected plaintext propagation into logs, APIs, or templates

Application state manipulation

Reproduction steps

Prisma schema

model User {
id Int @id @default(autoincrement())
email String @unique
secret String? /// @Encrypted?mode=strict
}

Normal encryption/decryption flow (expected behavior)

basic.cjs

require("dotenv/config");

const { PrismaClient } = require("@prisma/client");
const { fieldEncryptionExtension } = require("prisma-field-encryption");

console.log("START basic.cjs");

const prisma = new PrismaClient().$extends(fieldEncryptionExtension());

async function main() {
await prisma.user.deleteMany();

const u = await prisma.user.create({
data: { email: "[email protected]", secret: "TOPSECRET" },
});

const read = await prisma.user.findUnique({ where: { id: u.id } });
console.log("READ:", read);
}

main()
.then(() => console.log("DONE basic.cjs"))
.catch(console.error)
.finally(() => prisma.$disconnect());

Output:

START basic.cjs
READ: { id: 4, email: '[email protected]', secret: 'TOPSECRET' }
DONE basic.cjs

Encryption and decryption function correctly.

Corrupting valid ciphertext (strict mode behaves correctly)

strict_probe.cjs

require("dotenv/config");

const { PrismaClient } = require("@prisma/client");
const { fieldEncryptionExtension } = require("prisma-field-encryption");

console.log("START strict_probe.cjs");

const prisma = new PrismaClient().$extends(fieldEncryptionExtension());

async function main() {
await prisma.user.deleteMany();

await prisma.user.create({
data: { email: "[email protected]", secret: "TOPSECRET" },
});

const rows = await prisma.$queryRawUnsafe(
"SELECT secret FROM User WHERE email = '[email protected]'"
);

const raw = rows[0].secret;
console.log("RAW IN DB:", raw);

const corrupted = raw.slice(0, raw.length - 8);

await prisma.$executeRawUnsafe(
"UPDATE User SET secret = ? WHERE email = '[email protected]'",
corrupted
);

await prisma.user.findUnique({ where: { email: "[email protected]" } });
}

main()
.then(() => console.log("DONE"))
.catch((e) => console.error("ERROR strict_probe.cjs:", e))
.finally(() => prisma.$disconnect());

Output:

RAW IN DB: v1.aesgcm256....
ERROR strict_probe.cjs: Error: decryption error(s) encountered...

Strict mode correctly throws when ciphertext is invalid.

Strict-mode bypass via non-ciphertext injection

poison.cjs

require("dotenv/config");

const { PrismaClient } = require("@prisma/client");
const { fieldEncryptionExtension } = require("prisma-field-encryption");

console.log("START poison.cjs");

const prisma = new PrismaClient().$extends(fieldEncryptionExtension());

async function main() {
await prisma.user.upsert({
where: { email: "[email protected]" },
update: { secret: "TOPSECRET" },
create: { email: "[email protected]", secret: "TOPSECRET" },
});

await prisma.$executeRawUnsafe(
"UPDATE User SET secret = 'ENC:garbage' WHERE email = '[email protected]'"
);

const u = await prisma.user.findUnique({ where: { email: "[email protected]" } });
console.log("READ AFTER POISON:", u);
}

main()
.then(() => console.log("DONE poison.cjs"))
.catch(console.error)
.finally(() => prisma.$disconnect());

Output:

START poison.cjs
READ AFTER POISON: { id: 4, email: '[email protected]', secret: 'ENC:garbage' }
DONE poison.cjs

Even in strict mode, non-ciphertext values are returned without error.

Expected vs actual behavior

Expected (strict mode):

Only valid ciphertext accepted

Decryption failure → error

Non-ciphertext values → error

Actual:

Ciphertext → decrypted

Corrupted ciphertext → error

Non-ciphertext values → returned raw

Root cause analysis

Current logic appears to operate as:

If value matches ciphertext format → attempt decrypt

If decrypt fails and strict → throw

If value does not match ciphertext format → pass through as plaintext

Strict mode is only enforced when decryption is attempted. It is not enforced for values that do not resemble ciphertext.

This creates a fail-open behavior for non-ciphertext inputs in encrypted fields.

Suggested patch

Strict mode should reject any value that is not valid ciphertext.

Conceptual fix:

if (strictMode) {
if (!looksLikeCiphertext(value)) {
throw new Error("Strict mode: non-ciphertext value encountered");
}
}

Or:

if (strictMode) {
try {
return decrypt(value)
} catch {
throw new Error("Strict mode decryption failure")
}
}

Plaintext pass-through should not occur under strict mode.

Optional design improvements

Add explicit configuration:

fieldEncryptionExtension({
onDecryptError: "throw" | "null" | "redact"
})

or

strictRejectPlaintext: true

Severity assessment

This is not a cryptographic break.
It is a strict-mode enforcement inconsistency and integrity risk.

Relevant classification:

Integrity weakness

Fail-open behavior

Security hardening gap

Attack requires database write capability, but that is a realistic assumption in multiple threat models.

Conclusion

Strict mode currently protects against corrupted ciphertext but does not protect against non-ciphertext values stored in encrypted fields. Applications relying on strict mode for strong guarantees may unknowingly accept injected plaintext values.

The provided proof of concept demonstrates deterministic reproduction and should be sufficient for validation and remediation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions