Files
ginxsom/tests/metadata_scan_test.c
Laan Tungir 694219d003 Phase 1: server-side metadata detect-and-reject backstop
Add metadata_scan.c/.h: pure-C, dependency-free byte scanners for JPEG
(APP1 Exif/XMP, APP13 IPTC), PNG (eXIf, denylisted tEXt/iTXt/zTXt keys),
WebP (EXIF/XMP chunks), GIF (comment/XMP extensions), PDF (/Author,
/Producer, /CreationDate, etc.), TIFF (ExifIFD/GPS IFD pointers), HEIC
(Exif item payload). Conservative — when in doubt, flags.

Wire into handle_upload_request_with_validation() after SHA-256 compute,
before fopen: rejects with 415 + X-Reason: exif_detected, preserving
BUD-01 content addressing (server never rewrites). Add metadata_scan_enabled
config toggle (default true). Add to Makefile + Dockerfile.alpine-musl.

Tests: metadata_scan_test.c unit test (20 cases) + metadata_scan_test.sh
integration test (7 cases, run against live server).

See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 2).
2026-07-31 07:26:07 -04:00

267 lines
10 KiB
C

/*
* metadata_scan_test.c — Unit test for the metadata scanner.
*
* Builds a standalone binary (no FastCGI/sqlite deps) that crafts in-memory
* buffers for each format and asserts the scanner flags the dirty ones and
* passes the clean ones.
*
* Build:
* gcc -Wall -Wextra -std=gnu99 -O2 -Isrc \
* tests/metadata_scan_test.c src/metadata_scan.c \
* -o /tmp/metadata_scan_test
* Run:
* /tmp/metadata_scan_test
*
* Exit code 0 = all pass, 1 = at least one failure.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <stdarg.h>
#include "metadata_scan.h"
#include "app_log.h"
/* Stub for app_log so the test links without main.c. */
log_level_t g_log_level = LOG_WARN;
void app_log(log_level_t level, const char *format, ...) {
(void)level; (void)format;
}
static int g_pass = 0, g_fail = 0;
#define CHECK(cond, name) do { \
if (cond) { g_pass++; printf(" PASS: %s\n", name); } \
else { g_fail++; printf(" FAIL: %s\n", name); } \
} while (0)
/* ─── JPEG fixtures ────────────────────────────────────────────────────── */
/* Minimal clean JPEG: FF D8 FF E0 (JFIF) ... FF D9. No EXIF. */
static unsigned char clean_jpeg[] = {
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0xFF, 0xD9
};
/* JPEG with an APP1 EXIF segment. */
static unsigned char exif_jpeg[] = {
0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x08, 'E', 'x', 'i', 'f', 0x00, 0x00,
0xFF, 0xD9
};
/* JPEG with an APP1 XMP segment. */
static unsigned char xmp_jpeg[] = {
0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x23,
'h', 't', 't', 'p', ':', '/', '/', 'n', 's', '.', 'a', 'd', 'o', 'b',
'e', '.', 'c', 'o', 'm', '/', 'x', 'a', 'p', '/', '1', '.', '0', '/',
0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xD9
};
/* JPEG with APP13 Photoshop 8BIM (IPTC). */
static unsigned char iptc_jpeg[] = {
0xFF, 0xD8, 0xFF, 0xED, 0x00, 0x08, '8', 'B', 'I', 'M', 0x04, 0x04,
0xFF, 0xD9
};
/* ─── PNG fixtures ─────────────────────────────────────────────────────── */
static unsigned char png_sig[] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
/* Build a PNG with a single chunk of the given type + data. */
static unsigned char *build_png(const char *type, const unsigned char *chunk_data,
size_t chunk_len, size_t *out_len) {
/* 8 sig + 4 len + 4 type + data + 4 crc */
size_t total = 8 + 4 + 4 + chunk_len + 4;
unsigned char *buf = calloc(1, total);
memcpy(buf, png_sig, 8);
/* length (big-endian) */
buf[11] = (unsigned char)chunk_len;
memcpy(buf + 12, type, 4);
memcpy(buf + 16, chunk_data, chunk_len);
/* CRC left as 0 — scanner doesn't validate CRC */
*out_len = total;
return buf;
}
/* ─── WebP fixtures ────────────────────────────────────────────────────── */
static unsigned char webp_exif[] = {
'R', 'I', 'F', 'F', 0x14, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P',
'V', 'P', '8', 'X', 0x00, 0x00, 0x00, 0x00,
'E', 'X', 'I', 'F', 0x00, 0x00, 0x00, 0x00
};
static unsigned char webp_clean[] = {
'R', 'I', 'F', 'F', 0x14, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P',
'V', 'P', '8', 'L', 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
/* ─── GIF fixtures ─────────────────────────────────────────────────────── */
static unsigned char gif_comment[] = {
'G', 'I', 'F', '8', '9', 'a',
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, /* LSD: 1x1, no GCT */
0x21, 0xFE, /* comment extension */
0x05, 'h', 'e', 'l', 'l', 'o', /* sub-block */
0x00, /* terminator */
0x3B /* trailer */
};
static unsigned char gif_clean[] = {
'G', 'I', 'F', '8', '9', 'a',
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x3B
};
/* ─── PDF fixtures ─────────────────────────────────────────────────────── */
static const char *pdf_dirty = "%PDF-1.4\n/Author (John Doe)\n/Producer (pdf-lib)\n%%EOF";
static const char *pdf_clean = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF";
/* ─── TIFF fixtures ────────────────────────────────────────────────────── */
/* Little-endian TIFF with an ExifIFD pointer (tag 0x8769) in IFD0. */
static unsigned char tiff_exif[] = {
'I', 'I', 0x2A, 0x00, /* II + magic 42 */
0x08, 0x00, 0x00, 0x00, /* offset to IFD0 = 8 */
0x01, 0x00, /* 1 entry */
0x69, 0x87, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, /* ExifIFD tag */
0x00, 0x00, 0x00, 0x00 /* next IFD = 0 */
};
/* Little-endian TIFF with only a benign ImageWidth entry. */
static unsigned char tiff_clean[] = {
'I', 'I', 0x2A, 0x00,
0x08, 0x00, 0x00, 0x00,
0x01, 0x00,
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, /* ImageWidth=100 */
0x00, 0x00, 0x00, 0x00
};
/* ─── HEIC fixture ─────────────────────────────────────────────────────── */
static unsigned char heic_exif[] = {
0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'h', 'e', 'i', 'c',
'E', 'x', 'i', 'f', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
/* ─── run helpers ──────────────────────────────────────────────────────── */
static meta_scan_result_t run(const unsigned char *data, size_t len,
char *reason, size_t reason_size) {
return metadata_scan(data, len, "application/octet-stream",
reason, reason_size);
}
int main(void) {
char reason[128];
meta_scan_result_t r;
printf("=== metadata_scan unit tests ===\n");
/* JPEG */
printf("[JPEG]\n");
r = run(clean_jpeg, sizeof(clean_jpeg), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean JPEG passes");
r = run(exif_jpeg, sizeof(exif_jpeg), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "JPEG with EXIF rejected");
CHECK(strstr(reason, "Exif") != NULL, "EXIF reason string set");
r = run(xmp_jpeg, sizeof(xmp_jpeg), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "JPEG with XMP rejected");
r = run(iptc_jpeg, sizeof(iptc_jpeg), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "JPEG with IPTC/8BIM rejected");
/* PNG */
printf("[PNG]\n");
{
size_t len;
unsigned char *png;
/* clean PNG: just IHDR */
unsigned char ihdr[] = {0,0,0,0, 0,0,0,0, 8,0,0,0};
png = build_png("IHDR", ihdr, sizeof(ihdr), &len);
r = run(png, len, reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean PNG (IHDR only) passes");
free(png);
/* PNG with eXIf chunk */
unsigned char exif_chunk[] = {0x00, 0x00};
png = build_png("eXIf", exif_chunk, sizeof(exif_chunk), &len);
r = run(png, len, reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "PNG with eXIf chunk rejected");
free(png);
/* PNG with tEXt Software chunk */
unsigned char text_chunk[] = "Software\0ImageMagick";
png = build_png("tEXt", text_chunk, sizeof(text_chunk) - 1, &len);
r = run(png, len, reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "PNG with tEXt Software rejected");
free(png);
/* PNG with tEXt Comment chunk */
unsigned char comment_chunk[] = "Comment\0hello";
png = build_png("tEXt", comment_chunk, sizeof(comment_chunk) - 1, &len);
r = run(png, len, reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "PNG with tEXt Comment rejected");
free(png);
}
/* WebP */
printf("[WebP]\n");
r = run(webp_exif, sizeof(webp_exif), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "WebP with EXIF chunk rejected");
r = run(webp_clean, sizeof(webp_clean), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean WebP passes");
/* GIF */
printf("[GIF]\n");
r = run(gif_comment, sizeof(gif_comment), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "GIF with comment extension rejected");
r = run(gif_clean, sizeof(gif_clean), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean GIF passes");
/* PDF */
printf("[PDF]\n");
r = run((const unsigned char *)pdf_dirty, strlen(pdf_dirty), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "PDF with /Author rejected");
r = run((const unsigned char *)pdf_clean, strlen(pdf_clean), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean PDF passes");
/* TIFF */
printf("[TIFF]\n");
r = run(tiff_exif, sizeof(tiff_exif), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "TIFF with ExifIFD rejected");
r = run(tiff_clean, sizeof(tiff_clean), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "clean TIFF (no ExifIFD/GPS) passes");
/* HEIC */
printf("[HEIC]\n");
r = run(heic_exif, sizeof(heic_exif), reason, sizeof(reason));
CHECK(r == META_SCAN_FORBIDDEN_FOUND, "HEIC with Exif payload rejected");
/* Unknown format */
printf("[unknown]\n");
unsigned char unknown[] = {0xDE, 0xAD, 0xBE, 0xEF};
r = run(unknown, sizeof(unknown), reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "unknown format passes (no false positive)");
/* Empty / null */
printf("[edge]\n");
r = run(NULL, 0, reason, sizeof(reason));
CHECK(r == META_SCAN_OK, "null/empty input passes");
printf("\n=== %d passed, %d failed ===\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}