47ng / 47ng/prisma-field-encryption

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

Abierto
#152 2 comentarios 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
TypeScript
Estrellas
306
Forks
40
Métricas de merge de PR
Sin PR fusionados en 30 d

Descripción

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: "a@a.com", 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: 'a@a.com', 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: "a@a.com", secret: "TOPSECRET" },
});

const rows = await prisma.$queryRawUnsafe(
"SELECT secret FROM User WHERE email = 'a@a.com'"
);

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 = 'a@a.com'",
corrupted
);

await prisma.user.findUnique({ where: { email: "a@a.com" } });
}

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: "a@a.com" },
update: { secret: "TOPSECRET" },
create: { email: "a@a.com", secret: "TOPSECRET" },
});

await prisma.$executeRawUnsafe(
"UPDATE User SET secret = 'ENC:garbage' WHERE email = 'a@a.com'"
);

const u = await prisma.user.findUnique({ where: { email: "a@a.com" } });
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: 'a@a.com', 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.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.