diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index d64c303..cce2298 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -114,7 +114,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \ -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \ src/main.c src/admin_api.c src/admin_auth.c src/admin_event.c \ src/admin_handlers.c src/admin_interface.c src/admin_commands.c \ - src/bud04.c src/bud06.c src/bud08.c src/bud09.c \ + src/bud04.c src/bud06.c src/bud08.c src/bud09.c src/metadata_scan.c \ src/request_validator.c src/relay_client.c \ nostr_core_lib/nostr_core/core_relay_pool.c \ -o /build/ginxsom-fcgi_static \ diff --git a/Makefile b/Makefile index 86314e3..daea10f 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ BUILDDIR = build TARGET = $(BUILDDIR)/ginxsom-fcgi # Source files -SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/admin_interface.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/request_validator.c $(SRCDIR)/relay_client.c $(SRCDIR)/admin_commands.c +SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/admin_interface.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/metadata_scan.c $(SRCDIR)/request_validator.c $(SRCDIR)/relay_client.c $(SRCDIR)/admin_commands.c OBJECTS = $(SOURCES:$(SRCDIR)/%.c=$(BUILDDIR)/%.o) # Embedded web interface files diff --git a/src/main.c b/src/main.c index 7cd26c1..6a14f95 100644 --- a/src/main.c +++ b/src/main.c @@ -5,6 +5,7 @@ #define _GNU_SOURCE #include "ginxsom.h" +#include "metadata_scan.h" #include "relay_client.h" #include "admin_commands.h" #include "../nostr_core_lib/nostr_core/nostr_common.h" @@ -322,6 +323,7 @@ int initialize_database(const char *db_path) { " ('nip42_challenge_timeout', '600', 'NIP-42 challenge timeout in seconds')," " ('nip42_time_tolerance', '300', 'NIP-42 timestamp tolerance in seconds')," " ('enable_relay_connect', 'false', 'Enable connection to Nostr relays')," + " ('metadata_scan_enabled', 'true', 'Reject uploads containing EXIF/XMP/IPTC/PDF author metadata (privacy backstop)')," " ('kind_0_content', '{\"name\":\"Ginxsom Blossom Server\",\"about\":\"A Nostr-enabled Blossom media server\",\"picture\":\"\"}', 'JSON content for Kind 0 profile event')," " ('kind_10002_tags', '[\"wss://relay.laantungir.net\"]', 'JSON array of relay URLs for Kind 10002');"; @@ -1833,6 +1835,25 @@ void handle_upload_request_with_validation(nostr_request_result_t* validation_re char sha256_hex[65]; nostr_bytes_to_hex(hash, 32, sha256_hex); + // Privacy-metadata scan (Phase 1 backstop). Reject uploads carrying + // EXIF/XMP/IPTC/PDF author metadata before the bytes hit disk. The scan + // is read-only — it never rewrites, so the client-signed hash stays valid + // for the bytes we store (BUD-01 content addressing preserved). + if (metadata_scan_enabled()) { + char scan_reason[128] = {0}; + meta_scan_result_t scan = metadata_scan(file_data, file_size, + content_type, scan_reason, + sizeof(scan_reason)); + if (scan == META_SCAN_FORBIDDEN_FOUND) { + if (should_free_file_data) free(file_data); + send_upload_error_response(415, "unsupported_media_type", + "Privacy-sensitive metadata detected", + scan_reason); + log_request("PUT", "/upload", "metadata_rejected", 415); + return; + } + } + fflush(stderr); diff --git a/src/metadata_scan.c b/src/metadata_scan.c new file mode 100644 index 0000000..7cb2da2 --- /dev/null +++ b/src/metadata_scan.c @@ -0,0 +1,436 @@ +/* + * metadata_scan.c — Privacy-metadata detection for uploaded blobs. + * + * Pure-C, dependency-free byte scanners for JPEG / PNG / WebP / GIF / PDF / + * TIFF / HEIC. Conservative: when in doubt, flag. See metadata_scan.h and + * plans/blob-metadata-stripping.md (Part 2) for the design. + * + * The scanner never rewrites bytes — it only detects, so the caller can + * reject with 415 + X-Reason and let the client retry with stripped bytes. + * This preserves BUD-01 content addressing (the client-signed hash stays + * valid for the bytes the server stores). + */ + +#include "metadata_scan.h" +#include "app_log.h" +#include "ginxsom.h" +#include +#include + +/* ─── config toggle ────────────────────────────────────────────────────── */ +/* Read metadata_scan_enabled from the config table. Default true (server is + * the backstop). Mirrors nip94_enabled() in bud08.c. */ + +int metadata_scan_enabled(void) { + sqlite3 *db; + sqlite3_stmt *stmt; + int rc, enabled = 1; /* default enabled */ + + rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL); + if (rc) { + return 1; /* default enabled on DB error */ + } + + const char *sql = "SELECT value FROM config WHERE key = 'metadata_scan_enabled'"; + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc == SQLITE_OK) { + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + const char *value = (const char *)sqlite3_column_text(stmt, 0); + enabled = (value && strcmp(value, "true") == 0) ? 1 : 0; + } + sqlite3_finalize(stmt); + } + sqlite3_close(db); + return enabled; +} + +/* ─── helpers ──────────────────────────────────────────────────────────── */ + +/* Case-insensitive prefix match. */ +static int starts_with_ci(const unsigned char *hay, size_t hay_len, + const char *needle) { + size_t n = strlen(needle); + if (hay_len < n) return 0; + for (size_t i = 0; i < n; i++) { + char h = (char)hay[i], nd = needle[i]; + if (h >= 'A' && h <= 'Z') h += 32; + if (nd >= 'A' && nd <= 'Z') nd += 32; + if (h != nd) return 0; + } + return 1; +} + +/* Read a big-endian 16-bit value. */ +static unsigned int rd_be16(const unsigned char *p) { + return ((unsigned int)p[0] << 8) | (unsigned int)p[1]; +} + +/* Read a big-endian 32-bit value. */ +static unsigned int rd_be32(const unsigned char *p) { + return ((unsigned int)p[0] << 24) | ((unsigned int)p[1] << 16) | + ((unsigned int)p[2] << 8) | (unsigned int)p[3]; +} + +/* Read a little-endian 32-bit value. */ +static unsigned int rd_le32(const unsigned char *p) { + return ((unsigned int)p[3] << 24) | ((unsigned int)p[2] << 16) | + ((unsigned int)p[1] << 8) | (unsigned int)p[0]; +} + +/* Write a short reason into the caller's buffer. */ +static void set_reason(char *out, size_t out_size, const char *reason) { + if (!out || out_size == 0) return; + snprintf(out, out_size, "exif_detected: %s", reason); +} + +/* A found-forbidden shortcut macro: set reason and return. */ +#define FORBIDDEN(reason_str) \ + do { \ + set_reason(reason_out, reason_out_size, reason_str); \ + app_log(LOG_INFO, "METADATA_SCAN: rejected — %s\n", reason_str); \ + return META_SCAN_FORBIDDEN_FOUND; \ + } while (0) + +/* ─── JPEG ─────────────────────────────────────────────────────────────── */ +/* Segments: FF D8 FF Ex LL LL . We walk markers from offset 2. */ + +static meta_scan_result_t scan_jpeg(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + size_t i = 2; /* skip FF D8 */ + while (i + 4 <= size) { + if (data[i] != 0xFF) break; /* not a marker — image data */ + unsigned char marker = data[i + 1]; + /* SOI (D8), EOI (D9), RSTn (D0-D7) have no length payload. */ + if (marker == 0xD8 || marker == 0xD9) { i += 2; continue; } + if (marker >= 0xD0 && marker <= 0xD7) { i += 2; continue; } + /* SOS (DA) — start of scan; image data follows, stop walking. */ + if (marker == 0xDA) break; + if (i + 4 > size) break; + unsigned int seg_len = rd_be16(data + i + 2); + if (seg_len < 2 || i + 2 + seg_len > size) break; + const unsigned char *payload = data + i + 4; /* after FF Ex LL LL */ + size_t payload_len = seg_len - 2; + + /* APP1 — EXIF or XMP. */ + if (marker == 0xE1) { + if (payload_len >= 6 && memcmp(payload, "Exif\0\0", 6) == 0) { + FORBIDDEN("JPEG APP1 Exif segment"); + } + if (payload_len >= 29 && + starts_with_ci(payload, payload_len, + "http://ns.adobe.com/xap/1.0/")) { + FORBIDDEN("JPEG APP1 XMP segment"); + } + } + /* APP13 — Photoshop / IPTC (8BIM). */ + if (marker == 0xED) { + if (payload_len >= 4 && memcmp(payload, "8BIM", 4) == 0) { + FORBIDDEN("JPEG APP13 Photoshop/IPTC 8BIM segment"); + } + } + /* APP2 ICC_PROFILE is allowed (color, not PII). */ + i += 2 + seg_len; + } + return META_SCAN_OK; +} + +/* ─── PNG ──────────────────────────────────────────────────────────────── */ +/* Chunks: LL LL LL LL type CRC. We walk from offset 8 (after sig). */ + +/* Denylisted tEXt/iTXt/zTXt keys (case-insensitive prefix). */ +static int png_text_key_denied(const unsigned char *key, size_t key_len) { + static const char *denied[] = { + "Software", "Comment", "Author", "Description", "Copyright", + "XML:com.adobe.xmp", "Raw profile type exif", "Raw profile type", + "Source", "Creation Time", "Title", "Disclaimer", "Warning", + "Label", NULL + }; + for (int k = 0; denied[k]; k++) { + size_t n = strlen(denied[k]); + if (key_len >= n) { + int match = 1; + for (size_t i = 0; i < n; i++) { + char a = (char)key[i], b = denied[k][i]; + if (a >= 'A' && a <= 'Z') a += 32; + if (b >= 'A' && b <= 'Z') b += 32; + if (a != b) { match = 0; break; } + } + if (match) return 1; + } + } + return 0; +} + +static meta_scan_result_t scan_png(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + size_t i = 8; /* skip 8-byte signature */ + while (i + 8 <= size) { + unsigned int chunk_len = rd_be32(data + i); + char type[5]; + memcpy(type, data + i + 4, 4); type[4] = '\0'; + /* chunk data starts at i+8, CRC at i+8+chunk_len */ + if (i + 8 + chunk_len + 4 > size) break; + const unsigned char *cdata = data + i + 8; + + if (memcmp(type, "eXIf", 4) == 0) { + FORBIDDEN("PNG eXIf chunk"); + } + if (memcmp(type, "tEXt", 4) == 0 || memcmp(type, "zTXt", 4) == 0) { + /* key is null-terminated ASCII up to chunk_len */ + size_t klen = 0; + while (klen < chunk_len && cdata[klen] != 0) klen++; + if (klen > 0 && png_text_key_denied(cdata, klen)) { + FORBIDDEN("PNG tEXt/zTXt chunk with denylisted key"); + } + } + if (memcmp(type, "iTXt", 4) == 0) { + /* iTXt: keyword \0 compression_flag compression_method ... */ + size_t klen = 0; + while (klen < chunk_len && cdata[klen] != 0) klen++; + if (klen > 0 && png_text_key_denied(cdata, klen)) { + FORBIDDEN("PNG iTXt chunk with denylisted key"); + } + } + /* IEND ends the stream. */ + if (memcmp(type, "IEND", 4) == 0) break; + i += 8 + chunk_len + 4; + } + return META_SCAN_OK; +} + +/* ─── WebP ─────────────────────────────────────────────────────────────── */ +/* RIFF....WEBP then VP8/VP8L/VP8X chunks. We scan the container for EXIF */ +/* and XMP sub-chunks. */ + +static meta_scan_result_t scan_webp(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + /* RIFF <4 size> WEBP ... then sub-chunks: <4 type><4 size> */ + if (size < 12) return META_SCAN_OK; + size_t i = 12; + while (i + 8 <= size) { + char type[5]; + memcpy(type, data + i, 4); type[4] = '\0'; + unsigned int clen = rd_le32(data + i + 4); + if (i + 8 + clen > size) break; + if (memcmp(type, "EXIF", 4) == 0) { + FORBIDDEN("WebP EXIF chunk"); + } + if (memcmp(type, "XMP ", 4) == 0) { + FORBIDDEN("WebP XMP chunk"); + } + /* chunks are padded to even length */ + i += 8 + clen + (clen & 1); + } + return META_SCAN_OK; +} + +/* ─── GIF ──────────────────────────────────────────────────────────────── */ +/* 0x21 0xFE = comment extension; 0x21 0xFF = application extension. */ + +static meta_scan_result_t scan_gif(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + size_t i = 6; /* skip GIF87a/89a */ + /* skip logical screen descriptor (7 bytes) + optional global color table */ + if (i + 7 > size) return META_SCAN_OK; + unsigned char packed = data[i + 4]; + i += 7; + if (packed & 0x80) { + unsigned int gct_size = 3 * (1 << ((packed & 0x07) + 1)); + i += gct_size; + } + /* walk blocks */ + while (i < size) { + unsigned char b = data[i]; + if (b == 0x3B) break; /* trailer */ + if (b == 0x21 && i + 1 < size) { + unsigned char label = data[i + 1]; + if (label == 0xFE) { + FORBIDDEN("GIF comment extension (0x21 0xFE)"); + } + if (label == 0xFF) { + /* application extension: check for XMP Data */ + if (i + 14 < size && memcmp(data + i + 3, "XMP Data", 8) == 0) { + FORBIDDEN("GIF XMP Data application extension"); + } + } + /* skip sub-blocks: 0x21 label then size-prefixed sub-blocks */ + i += 2; + while (i < size && data[i] != 0) { + unsigned char sub = data[i]; + i += 1 + sub; + } + i++; /* skip 0 terminator */ + continue; + } + if (b == 0x2C) { /* image descriptor */ + i += 10; + if (i >= size) break; + unsigned char ipacked = data[i - 1]; + if (ipacked & 0x80) { + unsigned int lct = 3 * (1 << ((ipacked & 0x07) + 1)); + i += lct; + } + i++; /* LZW min code size */ + while (i < size && data[i] != 0) { + unsigned char sub = data[i]; + i += 1 + sub; + } + i++; + continue; + } + /* unknown block — bail to avoid mis-parsing */ + break; + } + return META_SCAN_OK; +} + +/* ─── PDF ──────────────────────────────────────────────────────────────── */ +/* Scan for forbidden dictionary keys in the raw byte stream. PDF is not */ +/* cleanly parseable without a full lexer, but the keys appear as literal */ +/* ASCII tokens (/Author, /Producer, etc.) and a byte scan is sufficient */ +/* for detection. We avoid matching inside stream content by also requiring */ +/* the token to look like a dictionary entry (preceded by whitespace/<' || prev == '[') { + return 1; + } + } + } + return 0; +} + +static meta_scan_result_t scan_pdf(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + static const char *keys[] = { + "/Author", "/Title", "/Subject", "/Keywords", "/Creator", + "/Producer", "/CreationDate", "/ModDate", "/XMP", "/Metadata", + NULL + }; + for (int k = 0; keys[k]; k++) { + if (pdf_key_present(data, size, keys[k])) { + char buf[64]; + snprintf(buf, sizeof(buf), "PDF %s entry", keys[k]); + FORBIDDEN(buf); + } + } + return META_SCAN_OK; +} + +/* ─── TIFF ─────────────────────────────────────────────────────────────── */ +/* TIFF is essentially all EXIF. Flag any TIFF unless it's a trivial */ +/* baseline (just IFD0 with no EXIF sub-IFD). For simplicity and safety, */ +/* flag all TIFFs that contain an ExifIFD pointer or GPS IFD pointer. */ + +static meta_scan_result_t scan_tiff(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + /* Determine endianness. */ + int big_endian; + if (size >= 2 && data[0] == 'I' && data[1] == 'I') big_endian = 0; + else if (size >= 2 && data[0] == 'M' && data[1] == 'M') big_endian = 1; + else return META_SCAN_OK; /* not actually TIFF */ + + if (size < 8) return META_SCAN_OK; + /* magic 42 at offset 2 */ + unsigned int magic = big_endian ? rd_be16(data + 2) + : (unsigned int)(data[2] | (data[3] << 8)); + if (magic != 42) return META_SCAN_OK; + + /* offset to first IFD */ + unsigned int ifd_off = big_endian ? rd_be32(data + 4) : rd_le32(data + 4); + if (ifd_off + 2 > size) return META_SCAN_OK; + + /* Walk IFD0 entries looking for ExifIFD (0x8769) or GPSIFD (0x8825). */ + unsigned int entry_count = big_endian ? rd_be16(data + ifd_off) + : (unsigned int)(data[ifd_off] | (data[ifd_off + 1] << 8)); + for (unsigned int e = 0; e < entry_count; e++) { + size_t off = ifd_off + 2 + e * 12; + if (off + 12 > size) break; + unsigned int tag = big_endian ? rd_be16(data + off) + : (unsigned int)(data[off] | (data[off + 1] << 8)); + if (tag == 0x8769) FORBIDDEN("TIFF ExifIFD sub-directory"); + if (tag == 0x8825) FORBIDDEN("TIFF GPS IFD"); + } + return META_SCAN_OK; +} + +/* ─── HEIC/HEIF ────────────────────────────────────────────────────────── */ +/* Full HEIC box parsing is complex; a byte-pattern scan for the Exif */ +/* item payload ("Exif\0\0") is sufficient for detection. */ + +static meta_scan_result_t scan_heic(const unsigned char *data, size_t size, + char *reason_out, size_t reason_out_size) { + /* Search for "Exif\0\0" anywhere in the file. */ + static const unsigned char exif_sig[] = {'E','x','i','f',0,0}; + if (size < sizeof(exif_sig)) return META_SCAN_OK; + for (size_t i = 0; i + sizeof(exif_sig) <= size; i++) { + if (memcmp(data + i, exif_sig, sizeof(exif_sig)) == 0) { + FORBIDDEN("HEIC/HEIF Exif item payload"); + } + } + return META_SCAN_OK; +} + +/* ─── dispatcher ───────────────────────────────────────────────────────── */ + +meta_scan_result_t metadata_scan(const unsigned char *data, + size_t size, + const char *content_type, + char *reason_out, + size_t reason_out_size) { + if (!data || size == 0) return META_SCAN_OK; + + /* Clear reason buffer. */ + if (reason_out && reason_out_size > 0) reason_out[0] = '\0'; + + /* Format detection by magic bytes. */ + if (size >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) { + return scan_jpeg(data, size, reason_out, reason_out_size); + } + if (size >= 8 && memcmp(data, "\x89PNG\r\n\x1a\n", 8) == 0) { + return scan_png(data, size, reason_out, reason_out_size); + } + if (size >= 12 && memcmp(data, "RIFF", 4) == 0 && + memcmp(data + 8, "WEBP", 4) == 0) { + return scan_webp(data, size, reason_out, reason_out_size); + } + if (size >= 6 && (memcmp(data, "GIF87a", 6) == 0 || + memcmp(data, "GIF89a", 6) == 0)) { + return scan_gif(data, size, reason_out, reason_out_size); + } + if (size >= 5 && memcmp(data, "%PDF-", 5) == 0) { + return scan_pdf(data, size, reason_out, reason_out_size); + } + if (size >= 4 && ((data[0] == 'I' && data[1] == 'I' && + data[2] == 0x2A && data[3] == 0) || + (data[0] == 'M' && data[1] == 'M' && + data[2] == 0 && data[3] == 0x2A))) { + return scan_tiff(data, size, reason_out, reason_out_size); + } + /* HEIC: ftyp box with heic/heix/heif/heims brand at offset 4..12. */ + if (size >= 12 && memcmp(data + 4, "ftyp", 4) == 0) { + const unsigned char *brand = data + 8; + if (memcmp(brand, "heic", 4) == 0 || memcmp(brand, "heix", 4) == 0 || + memcmp(brand, "heif", 4) == 0 || memcmp(brand, "heims", 4) == 0 || + memcmp(brand, "hevc", 4) == 0 || memcmp(brand, "heim", 4) == 0) { + return scan_heic(data, size, reason_out, reason_out_size); + } + } + + /* Unknown format — allow (no known metadata vector). */ + (void)content_type; /* currently unused; reserved for future hints */ + return META_SCAN_OK; +} diff --git a/src/metadata_scan.h b/src/metadata_scan.h new file mode 100644 index 0000000..020a4ea --- /dev/null +++ b/src/metadata_scan.h @@ -0,0 +1,65 @@ +/* + * metadata_scan.h — Privacy-metadata detection for uploaded blobs. + * + * The server-side backstop of the metadata-stripping pipeline. Scans the + * in-memory upload buffer for EXIF / XMP / IPTC / PNG text chunks / PDF + * author tags / GIF comments / HEIC Exif items and rejects the upload if + * forbidden metadata is present. + * + * The scanner is conservative: when in doubt, flag. The cost of a false + * positive is a client retry with stripped bytes; the cost of a false + * negative is a privacy leak. + * + * See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 2). + */ + +#ifndef METADATA_SCAN_H +#define METADATA_SCAN_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + META_SCAN_OK = 0, + META_SCAN_FORBIDDEN_FOUND = 1, + META_SCAN_ERROR = 2 +} meta_scan_result_t; + +/* + * Scan an in-memory blob for privacy-sensitive metadata. + * + * data — pointer to the uploaded bytes + * size — number of bytes + * content_type — MIME type from the Content-Type header (may be NULL) + * reason_out — buffer to receive a short human-readable reason string + * (e.g. "exif_detected: JPEG APP1 Exif segment") + * reason_out_size — size of reason_out; the reason is truncated to fit + * + * Returns: + * META_SCAN_OK — no forbidden metadata found + * META_SCAN_FORBIDDEN_FOUND — forbidden metadata detected; reason_out filled + * META_SCAN_ERROR — internal error (scan skipped) + * + * The format is auto-detected from the byte signature; content_type is used + * only as a hint (e.g. to distinguish a mislabeled TIFF). + */ +meta_scan_result_t metadata_scan(const unsigned char *data, + size_t size, + const char *content_type, + char *reason_out, + size_t reason_out_size); + +/* + * Read the metadata_scan_enabled config row. Returns 1 if the server should + * reject uploads carrying forbidden metadata (default), 0 if disabled. + */ +int metadata_scan_enabled(void); + +#ifdef __cplusplus +} +#endif + +#endif /* METADATA_SCAN_H */ diff --git a/tests/metadata_scan_test.c b/tests/metadata_scan_test.c new file mode 100644 index 0000000..112d960 --- /dev/null +++ b/tests/metadata_scan_test.c @@ -0,0 +1,266 @@ +/* + * 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 +#include +#include +#include +#include +#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; +} diff --git a/tests/metadata_scan_test.sh b/tests/metadata_scan_test.sh new file mode 100755 index 0000000..02f0ea6 --- /dev/null +++ b/tests/metadata_scan_test.sh @@ -0,0 +1,233 @@ +#!/bin/bash +# +# metadata_scan_test.sh — Integration test for the server-side metadata +# detect-and-reject backstop. +# +# Crafts dirty (EXIF/XMP/PDF-author) and clean fixtures with exiftool, uploads +# each to a running ginxsom instance, and asserts: +# - dirty fixtures → HTTP 415 + X-Reason: exif_detected +# - clean fixtures → HTTP 200 (or 409 if already exists) +# +# Requires: curl, nak, exiftool, sha256sum, base64, jq +# +# Usage: +# ./tests/metadata_scan_test.sh # default server +# ./tests/metadata_scan_test.sh https://blossom.example.net +# SERVER_URL=... TEST_PRIVKEY=... ./tests/metadata_scan_test.sh +# +# See plans/blob-metadata-stripping.md (Part 2 / Phase 1). + +set -euo pipefail + +SERVER_URL="${1:-${SERVER_URL:-https://blossom.laantungir.net}}" +UPLOAD_ENDPOINT="${SERVER_URL}/upload" +# Test privkey (from the existing upload_html_test.sh). Not a production key. +TEST_PRIVKEY="${TEST_PRIVKEY:-22cc83aa57928a2800234c939240c9a6f0f44a33ea3838a860ed38930b195afd}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; } +log_fail() { echo -e "${RED}[FAIL]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +PASS=0; FAIL=0 + +# ─── dependency check ──────────────────────────────────────────────────── +for tool in curl nak exiftool sha256sum base64 jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + log_fail "Missing required tool: $tool" + case "$tool" in + nak) echo " Install: https://github.com/fiatjaf/nak" ;; + exiftool) echo " Install: sudo apt install libimage-exiftool-perl" ;; + esac + exit 1 + fi +done + +# ─── helpers ───────────────────────────────────────────────────────────── +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Generate a kind 24242 upload auth event and base64-encode it. +make_auth() { + local sha="$1" size="$2" ct="$3" name="$4" + local exp; exp=$(( $(date +%s) + 3600 )) + local evt + evt=$(nak event -k 24242 -c "Upload ${name}" \ + --sec "$TEST_PRIVKEY" \ + -t "t=upload" \ + -t "x=${sha}" \ + -t "size=${size}" \ + -t "expiration=${exp}" 2>/dev/null) + if [[ -z "$evt" ]]; then + log_fail "nak event failed" + exit 1 + fi + printf '%s' "$evt" | base64 -w 0 +} + +# Upload a file and print: "HTTP_STATUS X-Reason-value" +upload_file() { + local file="$1" ct="$2" name="$3" + local sha size auth resp status xreason + sha=$(sha256sum "$file" | cut -d' ' -f1) + size=$(stat -c%s "$file") + auth=$(make_auth "$sha" "$size" "$ct" "$name") + resp=$(mktemp) + status=$(curl -s -w "%{http_code}" -X PUT \ + -H "Authorization: Nostr ${auth}" \ + -H "Content-Type: ${ct}" \ + --data-binary "@${file}" \ + -D "$TMPDIR/headers.txt" \ + "${UPLOAD_ENDPOINT}" -o "$resp" 2>/dev/null || echo "000") + xreason=$(grep -i '^X-Reason:' "$TMPDIR/headers.txt" 2>/dev/null | head -1 | sed -E 's/^[Xx]-[Rr]eason:[[:space:]]*//I' | tr -d '\r' || true) + rm -f "$resp" + printf '%s\t%s' "$status" "$xreason" +} + +# Assert an upload result. $1=expected_status, $2=actual, $3=test_name, $4=reason_substring (optional) +assert_status() { + local expected="$1" actual="$2" name="$3" reason_sub="${4:-}" + local status_part; status_part=$(printf '%s' "$actual" | cut -f1) + local reason_part; reason_part=$(printf '%s' "$actual" | cut -f2) + if [[ "$status_part" == "$expected" ]]; then + if [[ -z "$reason_sub" ]] || [[ "$reason_part" == *"$reason_sub"* ]]; then + log_pass "$name (HTTP $status_part${reason_part:+, X-Reason: $reason_part})" + PASS=$((PASS+1)) + else + log_fail "$name: status OK ($status_part) but X-Reason missing '$reason_sub' (got: '$reason_part')" + FAIL=$((FAIL+1)) + fi + else + log_fail "$name: expected HTTP $expected, got $status_part (X-Reason: $reason_part)" + FAIL=$((FAIL+1)) + fi +} + +# ─── craft fixtures ────────────────────────────────────────────────────── +log_info "Crafting test fixtures in $TMPDIR" + +# 1. Clean JPEG (no EXIF) — 1x1 pixel via ImageMagick if available, else raw bytes. +CLEAN_JPEG="$TMPDIR/clean.jpg" +if command -v convert >/dev/null 2>&1; then + convert -size 1x1 xc:white "$CLEAN_JPEG" +else + # Minimal valid JPEG: FF D8 FF E0 00 10 JFIF... FF D9 + printf '\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9' > "$CLEAN_JPEG" +fi + +# 2. Dirty JPEG with EXIF GPS + Author (via exiftool). +DIRTY_JPEG="$TMPDIR/dirty_exif.jpg" +cp "$CLEAN_JPEG" "$DIRTY_JPEG" +exiftool -overwrite_original -q \ + -GPSLatitude=48.8584 -GPSLongitude=2.2945 -GPSLatitudeRef=N -GPSLongitudeRef=E \ + -Author="Test Author" -Make="TestCam" -Model="TestModel" \ + "$DIRTY_JPEG" 2>/dev/null || true + +# 3. Dirty JPEG with XMP packet. +DIRTY_XMP_JPEG="$TMPDIR/dirty_xmp.jpg" +cp "$CLEAN_JPEG" "$DIRTY_XMP_JPEG" +exiftool -overwrite_original -q \ + -XMP-dc:Creator="XMP Test Creator" -XMP-dc:Rights="CC BY" \ + "$DIRTY_XMP_JPEG" 2>/dev/null || true + +# 4. Clean PNG (no text chunks). +CLEAN_PNG="$TMPDIR/clean.png" +if command -v convert >/dev/null 2>&1; then + convert -size 1x1 xc:white "$CLEAN_PNG" +else + # Minimal 1x1 PNG (IHDR + IDAT + IEND) + printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\x8a\xeb\x18\x95\x00\x00\x00\x00IEND\xaeB`\x82' > "$CLEAN_PNG" +fi + +# 5. Dirty PNG with eXIf chunk. +DIRTY_PNG="$TMPDIR/dirty_exif.png" +cp "$CLEAN_PNG" "$DIRTY_PNG" +exiftool -overwrite_original -q \ + -Author="PNG Author" -Comment="secret" \ + "$DIRTY_PNG" 2>/dev/null || true + +# 6. Dirty PDF with /Author. +DIRTY_PDF="$TMPDIR/dirty.pdf" +cat > "$DIRTY_PDF" <<'PDF' +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +<< /Author (Secret Author) /Producer (Test Producer) /CreationDate (D:20260101000000) >> +trailer +<< /Root 1 0 R >> +%%EOF +PDF + +# 7. Clean PDF (no author metadata). +CLEAN_PDF="$TMPDIR/clean.pdf" +cat > "$CLEAN_PDF" <<'PDF' +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +trailer +<< /Root 1 0 R >> +%%EOF +PDF + +# ─── run tests ─────────────────────────────────────────────────────────── +echo "" +log_info "Server: $SERVER_URL" +log_info "Upload endpoint: $UPLOAD_ENDPOINT" +echo "" + +log_info "Test 1: clean JPEG (should pass)" +r=$(upload_file "$CLEAN_JPEG" "image/jpeg" "clean.jpg") +assert_status "200" "$r" "clean JPEG accepted" || true +# 409 is also acceptable (blob already exists from a prior run) +if [[ "$(printf '%s' "$r" | cut -f1)" == "409" ]]; then + log_warn " (409 — blob already exists from prior run; treating as pass)" + PASS=$((PASS+1)); FAIL=$((FAIL-1)) +fi + +log_info "Test 2: JPEG with EXIF GPS + Author (should be rejected)" +r=$(upload_file "$DIRTY_JPEG" "image/jpeg" "dirty_exif.jpg") +assert_status "415" "$r" "EXIF JPEG rejected" "exif_detected" || true + +log_info "Test 3: JPEG with XMP packet (should be rejected)" +r=$(upload_file "$DIRTY_XMP_JPEG" "image/jpeg" "dirty_xmp.jpg") +assert_status "415" "$r" "XMP JPEG rejected" "exif_detected" || true + +log_info "Test 4: clean PNG (should pass)" +r=$(upload_file "$CLEAN_PNG" "image/png" "clean.png") +assert_status "200" "$r" "clean PNG accepted" || true +if [[ "$(printf '%s' "$r" | cut -f1)" == "409" ]]; then + log_warn " (409 — blob already exists; treating as pass)" + PASS=$((PASS+1)); FAIL=$((FAIL-1)) +fi + +log_info "Test 5: PNG with EXIF/text chunks (should be rejected)" +r=$(upload_file "$DIRTY_PNG" "image/png" "dirty_exif.png") +assert_status "415" "$r" "dirty PNG rejected" "exif_detected" || true + +log_info "Test 6: PDF with /Author (should be rejected)" +r=$(upload_file "$DIRTY_PDF" "application/pdf" "dirty.pdf") +assert_status "415" "$r" "dirty PDF rejected" "exif_detected" || true + +log_info "Test 7: clean PDF (should pass)" +r=$(upload_file "$CLEAN_PDF" "application/pdf" "clean.pdf") +assert_status "200" "$r" "clean PDF accepted" || true +if [[ "$(printf '%s' "$r" | cut -f1)" == "409" ]]; then + log_warn " (409 — blob already exists; treating as pass)" + PASS=$((PASS+1)); FAIL=$((FAIL-1)) +fi + +# ─── summary ───────────────────────────────────────────────────────────── +echo "" +echo "==========================================" +echo -e " ${GREEN}PASSED: $PASS${NC} ${RED}FAILED: $FAIL${NC}" +echo "==========================================" +[[ "$FAIL" -eq 0 ]] && exit 0 || exit 1