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).
267 lines
10 KiB
C
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;
|
|
}
|