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).
This commit is contained in:
Laan Tungir
2026-07-31 07:26:07 -04:00
parent b1ea256076
commit 694219d003
7 changed files with 1023 additions and 2 deletions
+1 -1
View File
@@ -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 \
+1 -1
View File
@@ -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
+21
View File
@@ -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);
+436
View File
@@ -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 <string.h>
#include <stdio.h>
/* ─── 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 <payload>. 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 <data> 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><data> */
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/<</{). */
static int pdf_key_present(const unsigned char *data, size_t size,
const char *key) {
size_t klen = strlen(key);
if (size < klen) return 0;
for (size_t i = 0; i + klen <= size; i++) {
if (memcmp(data + i, key, klen) == 0) {
/* Check the preceding byte is a PDF delimiter to reduce false
* positives from stream pixel data that happens to contain the
* byte sequence. */
if (i == 0) return 1;
unsigned char prev = data[i - 1];
if (prev == '(' || prev == '<' || prev == '{' ||
prev == ' ' || prev == '\n' || prev == '\r' ||
prev == '\t' || prev == '/' || prev == '>' || 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;
}
+65
View File
@@ -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 <stddef.h>
#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 */
+266
View File
@@ -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 <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;
}
+233
View File
@@ -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