CSP Builder — C source
Build a Content-Security-Policy header interactively. Toggle directives, add sources, see the assembled header in real time — with a security score that flags unsafe sources.
This is the C implementation — the same logic the interactive tool runs, in a shareable, citable form.
/*
* csp-builder — Content-Security-Policy assembly, parsing, linting and scoring.
*
* Language: C (C11, standard library only)
* Source: CosmoDev polyglot showcase port of the CSP Builder tool, ported
* from src/lib/csp-builder.ts (the canonical TypeScript
* implementation).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* A CSP is modeled as a map of directive -> source list. Build assembles the
* map into the header string (directives in catalog order, then any unknown
* directives in insertion order); parse reads a header back into the map.
* Neither function ever fails on content — parse is lenient by design so a
* pasted real-world header always yields something editable.
*
* C has no dictionary, and JavaScript objects iterate in insertion order, so
* the policy is an ordered vector of entries. Lookup is a linear scan: a CSP
* has at most a couple of dozen directives, so this is both simpler and
* faster than hashing.
*
* Build: cc -std=c11 csp-builder.c
*/
#include <ctype.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* -------------------------------------------------------------------------
* Catalog types
* ------------------------------------------------------------------------- */
/** How a directive takes its value: a source list, a single URL, or a bare flag. */
typedef enum {
CSP_KIND_SOURCES,
CSP_KIND_URL,
CSP_KIND_FLAG
} csp_directive_kind;
/** How much exposure the directive controls (drives UI emphasis). */
typedef enum {
CSP_RISK_LOW,
CSP_RISK_MEDIUM,
CSP_RISK_HIGH
} csp_directive_risk;
/** One entry of the built-in directive catalog. */
typedef struct {
const char *name;
csp_directive_kind kind;
const char *description;
csp_directive_risk risk;
/** Sources inserted when the directive is enabled in the UI (NULL-terminated). */
const char *const *default_sources;
} csp_directive_info;
static const char *const CSP_SRC_SELF[] = { "'self'", NULL };
static const char *const CSP_SRC_NONE[] = { "'none'", NULL };
static const char *const CSP_SRC_EMPTY[] = { NULL };
/** The catalog, in canonical build/display order. */
static const csp_directive_info CSP_DIRECTIVES[] = {
{ "default-src", CSP_KIND_SOURCES,
"Fallback for every fetch directive you do not set explicitly. Set this "
"first, then tighten individual directives.",
CSP_RISK_MEDIUM, CSP_SRC_SELF },
{ "script-src", CSP_KIND_SOURCES,
"Where scripts may load from. The single most important XSS control - "
"keep it as tight as you can.",
CSP_RISK_HIGH, CSP_SRC_SELF },
{ "style-src", CSP_KIND_SOURCES,
"Where stylesheets may load from. Also gates inline style attributes.",
CSP_RISK_MEDIUM, CSP_SRC_SELF },
{ "img-src", CSP_KIND_SOURCES,
"Where images and favicons may load from.",
CSP_RISK_LOW, CSP_SRC_SELF },
{ "connect-src", CSP_KIND_SOURCES,
"Which URLs scripts may connect to (fetch, XHR, WebSocket). Your "
"data-exfiltration boundary.",
CSP_RISK_MEDIUM, CSP_SRC_SELF },
{ "font-src", CSP_KIND_SOURCES,
"Where web fonts may load from.",
CSP_RISK_LOW, CSP_SRC_SELF },
{ "frame-src", CSP_KIND_SOURCES,
"Which URLs may be embedded as child browsing contexts (iframe, frame).",
CSP_RISK_LOW, CSP_SRC_SELF },
{ "media-src", CSP_KIND_SOURCES,
"Where audio and video may load from.",
CSP_RISK_LOW, CSP_SRC_SELF },
{ "object-src", CSP_KIND_SOURCES,
"Where plugin content (object, embed, applet) may load from. Almost "
"always should be 'none'.",
CSP_RISK_HIGH, CSP_SRC_NONE },
{ "base-uri", CSP_KIND_SOURCES,
"Which URLs may set the document base. Restrict to 'self' to block "
"<base> hijacking of relative URLs.",
CSP_RISK_HIGH, CSP_SRC_SELF },
{ "form-action", CSP_KIND_SOURCES,
"Where forms may submit to. Does not fall back to default-src.",
CSP_RISK_MEDIUM, CSP_SRC_SELF },
{ "frame-ancestors", CSP_KIND_SOURCES,
"Which parents may embed this page (clickjacking control). Ignored "
"inside a <meta> tag - header delivery only.",
CSP_RISK_MEDIUM, CSP_SRC_SELF },
{ "report-uri", CSP_KIND_URL,
"URL where the browser posts violation reports. Pair with a report "
"collector.",
CSP_RISK_LOW, CSP_SRC_EMPTY },
{ "upgrade-insecure-requests", CSP_KIND_FLAG,
"Tells the browser to rewrite http:// subresource requests to https://.",
CSP_RISK_LOW, CSP_SRC_EMPTY },
{ "block-all-mixed-content", CSP_KIND_FLAG,
"Blocks loading of any http:// subresource on an https:// page.",
CSP_RISK_LOW, CSP_SRC_EMPTY },
};
static const size_t CSP_DIRECTIVE_COUNT =
sizeof(CSP_DIRECTIVES) / sizeof(CSP_DIRECTIVES[0]);
/** Source presets offered in the UI when adding a source to a directive. */
static const char *const CSP_COMMON_SOURCES[] = {
"'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'",
"'strict-dynamic'", "data:", "blob:", "https:", NULL
};
/* -------------------------------------------------------------------------
* Policy map — an insertion-ordered vector of directive entries
* ------------------------------------------------------------------------- */
typedef struct {
char *name; /* owned, lowercase */
char **sources; /* owned vector of owned strings */
size_t source_count;
} csp_entry;
typedef struct {
csp_entry *entries;
size_t count;
size_t capacity;
} csp_policy;
/** Initialize an empty policy. */
void csp_policy_init(csp_policy *policy)
{
policy->entries = NULL;
policy->count = 0;
policy->capacity = 0;
}
/** Release every allocation owned by the policy. */
void csp_policy_free(csp_policy *policy)
{
if (policy == NULL) return;
for (size_t i = 0; i < policy->count; i++) {
for (size_t j = 0; j < policy->entries[i].source_count; j++)
free(policy->entries[i].sources[j]);
free(policy->entries[i].sources);
free(policy->entries[i].name);
}
free(policy->entries);
csp_policy_init(policy);
}
/** strdup is POSIX, not C11 — keep the file freestanding on the C standard. */
static char *csp_strdup(const char *s)
{
size_t len = strlen(s) + 1;
char *copy = malloc(len);
if (copy != NULL) memcpy(copy, s, len);
return copy;
}
/** Lowercase copy of `s`. Caller frees. */
static char *csp_strdup_lower(const char *s)
{
char *copy = csp_strdup(s);
if (copy == NULL) return NULL;
for (char *p = copy; *p != '\0'; p++)
*p = (char)tolower((unsigned char)*p);
return copy;
}
/** Look up a directive by exact (lowercase) name, or NULL. */
csp_entry *csp_policy_get(const csp_policy *policy, const char *name)
{
for (size_t i = 0; i < policy->count; i++) {
if (strcmp(policy->entries[i].name, name) == 0)
return &policy->entries[i];
}
return NULL;
}
/**
* Set (or replace) a directive's source list. `sources` is a NULL-terminated
* array of C strings; pass CSP_SRC_EMPTY for flag directives. Returns false
* only on allocation failure.
*/
bool csp_policy_set(csp_policy *policy, const char *name,
const char *const *sources)
{
char *lower = csp_strdup_lower(name);
csp_entry *existing;
size_t n = 0;
char **vec;
if (lower == NULL) return false;
while (sources != NULL && sources[n] != NULL) n++;
vec = (n > 0) ? calloc(n, sizeof(*vec)) : NULL;
if (n > 0 && vec == NULL) {
free(lower);
return false;
}
for (size_t i = 0; i < n; i++) {
vec[i] = csp_strdup(sources[i]);
if (vec[i] == NULL) {
for (size_t j = 0; j < i; j++) free(vec[j]);
free(vec);
free(lower);
return false;
}
}
existing = csp_policy_get(policy, lower);
if (existing != NULL) {
for (size_t j = 0; j < existing->source_count; j++)
free(existing->sources[j]);
free(existing->sources);
existing->sources = vec;
existing->source_count = n;
free(lower);
return true;
}
if (policy->count == policy->capacity) {
size_t cap = (policy->capacity == 0) ? 8 : policy->capacity * 2;
csp_entry *grown = realloc(policy->entries, cap * sizeof(*grown));
if (grown == NULL) {
for (size_t j = 0; j < n; j++) free(vec[j]);
free(vec);
free(lower);
return false;
}
policy->entries = grown;
policy->capacity = cap;
}
policy->entries[policy->count].name = lower;
policy->entries[policy->count].sources = vec;
policy->entries[policy->count].source_count = n;
policy->count++;
return true;
}
/* -------------------------------------------------------------------------
* A growable string, so build() can concatenate without size guessing
* ------------------------------------------------------------------------- */
typedef struct {
char *data;
size_t len;
size_t capacity;
} csp_buffer;
static bool csp_buffer_append(csp_buffer *buf, const char *text)
{
size_t add = strlen(text);
if (buf->len + add + 1 > buf->capacity) {
size_t cap = (buf->capacity == 0) ? 128 : buf->capacity;
char *grown;
while (buf->len + add + 1 > cap) cap *= 2;
grown = realloc(buf->data, cap);
if (grown == NULL) return false;
buf->data = grown;
buf->capacity = cap;
}
memcpy(buf->data + buf->len, text, add + 1);
buf->len += add;
return true;
}
/* -------------------------------------------------------------------------
* Catalog predicates
* ------------------------------------------------------------------------- */
/** True when the directive takes no value — emitted as a bare name. */
static bool csp_is_flag_directive(const char *name)
{
for (size_t i = 0; i < CSP_DIRECTIVE_COUNT; i++) {
if (CSP_DIRECTIVES[i].kind == CSP_KIND_FLAG &&
strcmp(CSP_DIRECTIVES[i].name, name) == 0)
return true;
}
return false;
}
/** True when the directive appears in the built-in catalog. */
static bool csp_is_known_directive(const char *name)
{
for (size_t i = 0; i < CSP_DIRECTIVE_COUNT; i++) {
if (strcmp(CSP_DIRECTIVES[i].name, name) == 0) return true;
}
return false;
}
/* -------------------------------------------------------------------------
* build
* ------------------------------------------------------------------------- */
static bool csp_emit(csp_buffer *buf, const csp_policy *policy,
const char *name, bool *first)
{
const csp_entry *entry = csp_policy_get(policy, name);
if (entry == NULL) return true;
if (!csp_is_flag_directive(name) && entry->source_count == 0) return true;
if (!*first && !csp_buffer_append(buf, "; ")) return false;
*first = false;
if (!csp_buffer_append(buf, name)) return false;
if (csp_is_flag_directive(name)) return true;
for (size_t i = 0; i < entry->source_count; i++) {
if (!csp_buffer_append(buf, " ")) return false;
if (!csp_buffer_append(buf, entry->sources[i])) return false;
}
return true;
}
/**
* Assemble a policy map into the `Content-Security-Policy` header value.
* Known directives emit in catalog order, unknown directives after them in
* insertion order. Flag directives emit as a bare name; source/url directives
* with an empty list are omitted (a valueless directive is invalid CSP).
* An empty map yields an empty string. Caller frees the result.
*/
char *csp_build(const csp_policy *policy)
{
csp_buffer buf = { NULL, 0, 0 };
bool first = true;
if (!csp_buffer_append(&buf, "")) return NULL;
for (size_t i = 0; i < CSP_DIRECTIVE_COUNT; i++) {
if (!csp_emit(&buf, policy, CSP_DIRECTIVES[i].name, &first)) {
free(buf.data);
return NULL;
}
}
for (size_t i = 0; i < policy->count; i++) {
if (csp_is_known_directive(policy->entries[i].name)) continue;
if (!csp_emit(&buf, policy, policy->entries[i].name, &first)) {
free(buf.data);
return NULL;
}
}
return buf.data;
}
/* -------------------------------------------------------------------------
* parse
* ------------------------------------------------------------------------- */
/**
* Parse a CSP header value back into a policy map. Lenient: splits on
* semicolons and whitespace, lowercases directive names, ignores empty tokens,
* and strips an optional leading `Content-Security-Policy:` label so a pasted
* full header line works. Duplicate directives keep only the first occurrence
* (matching how browsers honor them). Never fails on content; garbage in,
* empty policy out. Returns false only on allocation failure.
*/
bool csp_parse(const char *header, csp_policy *policy)
{
static const char LABEL[] = "content-security-policy";
const char *text, *end, *cursor;
char *lowered;
csp_policy_init(policy);
if (header == NULL) return true;
text = header;
while (*text != '\0' && isspace((unsigned char)*text)) text++;
end = text + strlen(text);
while (end > text && isspace((unsigned char)end[-1])) end--;
/* Strip an optional `Content-Security-Policy :` label (case-insensitive). */
lowered = csp_strdup_lower(text);
if (lowered == NULL) return false;
if (strncmp(lowered, LABEL, sizeof(LABEL) - 1) == 0) {
const char *after = text + sizeof(LABEL) - 1;
while (after < end && isspace((unsigned char)*after)) after++;
if (after < end && *after == ':') text = after + 1;
}
free(lowered);
cursor = text;
while (cursor < end) {
const char *semi = memchr(cursor, ';', (size_t)(end - cursor));
const char *token_end = (semi != NULL) ? semi : end;
const char *word = cursor;
bool is_name = true;
char *name = NULL;
while (word < token_end) {
const char *word_end;
size_t word_len;
char *value;
while (word < token_end && isspace((unsigned char)*word)) word++;
if (word >= token_end) break;
word_end = word;
while (word_end < token_end && !isspace((unsigned char)*word_end))
word_end++;
word_len = (size_t)(word_end - word);
value = malloc(word_len + 1);
if (value == NULL) {
free(name);
csp_policy_free(policy);
return false;
}
memcpy(value, word, word_len);
value[word_len] = '\0';
if (is_name) {
name = csp_strdup_lower(value);
free(value);
if (name == NULL) {
csp_policy_free(policy);
return false;
}
is_name = false;
/* Duplicate directive: browsers honor the first occurrence. */
if (csp_policy_get(policy, name) != NULL) {
free(name);
name = NULL;
break;
}
if (!csp_policy_set(policy, name, CSP_SRC_EMPTY)) {
free(name);
csp_policy_free(policy);
return false;
}
} else {
csp_entry *entry = csp_policy_get(policy, name);
char **grown = realloc(entry->sources,
(entry->source_count + 1) *
sizeof(*grown));
if (grown == NULL) {
free(value);
free(name);
csp_policy_free(policy);
return false;
}
entry->sources = grown;
entry->sources[entry->source_count++] = value;
}
word = word_end;
}
free(name);
cursor = (semi != NULL) ? semi + 1 : end;
}
return true;
}
/* -------------------------------------------------------------------------
* Risk analysis
* ------------------------------------------------------------------------- */
/** Sources treated as security-weakening, compared case-insensitively. */
static const char *const CSP_RISKY_SOURCES[] = {
"'unsafe-inline'", "'unsafe-eval'", "data:", "http:", "*", NULL
};
/** Trimmed + lowercased copy of `source`. Caller frees. */
static char *csp_normalize_source(const char *source)
{
const char *start = source, *end;
size_t len;
char *out;
while (*start != '\0' && isspace((unsigned char)*start)) start++;
end = start + strlen(start);
while (end > start && isspace((unsigned char)end[-1])) end--;
len = (size_t)(end - start);
out = malloc(len + 1);
if (out == NULL) return NULL;
for (size_t i = 0; i < len; i++)
out[i] = (char)tolower((unsigned char)start[i]);
out[len] = '\0';
return out;
}
/**
* True when a source weakens the policy: 'unsafe-inline', 'unsafe-eval',
* 'data:', 'http:', the bare wildcard '*', or any insecure http:// URL.
* 'self', 'none', 'strict-dynamic', 'blob:', 'https:' and https URLs are fine.
*/
bool csp_is_risky_source(const char *source)
{
char *s = csp_normalize_source(source);
bool risky = false;
if (s == NULL) return false;
for (size_t i = 0; CSP_RISKY_SOURCES[i] != NULL; i++) {
if (strcmp(s, CSP_RISKY_SOURCES[i]) == 0) {
risky = true;
break;
}
}
if (!risky) risky = (strncmp(s, "http://", 7) == 0);
free(s);
return risky;
}
/** Short human explanation for each risky source (tooltip text in the UI). */
static const char *csp_lookup_explanation(const char *normalized)
{
if (strcmp(normalized, "'unsafe-inline'") == 0)
return "Allows inline <script>/<style> and event handlers - defeats "
"most of CSP's XSS protection.";
if (strcmp(normalized, "'unsafe-eval'") == 0)
return "Allows eval() and similar code execution - weakens XSS "
"protection.";
if (strcmp(normalized, "*") == 0)
return "Allows every origin - effectively no restriction for this "
"directive.";
if (strcmp(normalized, "data:") == 0)
return "data: URIs can carry arbitrary payloads and are same-origin - "
"attackers can smuggle content through them.";
if (strcmp(normalized, "http:") == 0)
return "Allows insecure origins - a network attacker can inject or "
"tamper with subresources.";
return NULL;
}
/** Explanation for any risky source; falls back to the generic insecure-origin text. */
const char *csp_risk_explanation(const char *source)
{
char *key = csp_normalize_source(source);
const char *found;
if (key == NULL)
return "Insecure http:// URL - traffic can be tampered with in transit.";
found = csp_lookup_explanation(key);
free(key);
return (found != NULL)
? found
: "Insecure http:// URL - traffic can be tampered with in transit.";
}
/* -------------------------------------------------------------------------
* validate
* ------------------------------------------------------------------------- */
/** One policy problem: either policy-wide (directive is "") or a risky source. */
typedef struct {
char *directive; /* owned; "" for policy-wide issues */
char *source; /* owned; NULL for policy-wide issues */
char *message; /* owned */
} csp_issue;
typedef struct {
csp_issue *items;
size_t count;
size_t capacity;
} csp_issue_list;
void csp_issue_list_free(csp_issue_list *list)
{
if (list == NULL) return;
for (size_t i = 0; i < list->count; i++) {
free(list->items[i].directive);
free(list->items[i].source);
free(list->items[i].message);
}
free(list->items);
list->items = NULL;
list->count = 0;
list->capacity = 0;
}
static bool csp_issue_push(csp_issue_list *list, const char *directive,
const char *source, const char *message)
{
if (list->count == list->capacity) {
size_t cap = (list->capacity == 0) ? 8 : list->capacity * 2;
csp_issue *grown = realloc(list->items, cap * sizeof(*grown));
if (grown == NULL) return false;
list->items = grown;
list->capacity = cap;
}
list->items[list->count].directive = csp_strdup(directive);
list->items[list->count].source = (source != NULL) ? csp_strdup(source) : NULL;
list->items[list->count].message = csp_strdup(message);
if (list->items[list->count].directive == NULL ||
list->items[list->count].message == NULL ||
(source != NULL && list->items[list->count].source == NULL)) {
free(list->items[list->count].directive);
free(list->items[list->count].source);
free(list->items[list->count].message);
return false;
}
list->count++;
return true;
}
/**
* Lint a policy: warns when default-src is missing (unset directives fall back
* to the browser's allow-everything default) and flags every risky source.
* Returns false only on allocation failure.
*/
bool csp_validate(const csp_policy *policy, csp_issue_list *issues)
{
issues->items = NULL;
issues->count = 0;
issues->capacity = 0;
if (csp_policy_get(policy, "default-src") == NULL) {
if (!csp_issue_push(issues, "", NULL,
"No default-src - every directive you don't set "
"explicitly falls back to the browser's permissive "
"default."))
goto fail;
}
for (size_t i = 0; i < policy->count; i++) {
const csp_entry *entry = &policy->entries[i];
for (size_t j = 0; j < entry->source_count; j++) {
const char *src = entry->sources[j];
char message[512];
if (!csp_is_risky_source(src)) continue;
snprintf(message, sizeof(message), "%s: %s weakens this policy - %s",
entry->name, src, csp_risk_explanation(src));
if (!csp_issue_push(issues, entry->name, src, message)) goto fail;
}
}
return true;
fail:
csp_issue_list_free(issues);
return false;
}
/* -------------------------------------------------------------------------
* securityScore
* ------------------------------------------------------------------------- */
/** Score penalty per risky source (case-insensitive key). */
static int csp_score_penalty(const char *normalized)
{
if (strcmp(normalized, "'unsafe-inline'") == 0) return 20;
if (strcmp(normalized, "'unsafe-eval'") == 0) return 15;
if (strcmp(normalized, "*") == 0) return 20;
if (strcmp(normalized, "data:") == 0) return 10;
if (strcmp(normalized, "http:") == 0) return 10;
return (strncmp(normalized, "http://", 7) == 0) ? 10 : 0;
}
/**
* Security score, 0-100. Starts at 100; each risky source subtracts its
* penalty (insecure http:// URLs subtract 10), and a missing default-src
* subtracts 10. Clamped to 0-100. Deterministic.
*/
int csp_security_score(const csp_policy *policy)
{
int score = 100;
if (csp_policy_get(policy, "default-src") == NULL) score -= 10;
for (size_t i = 0; i < policy->count; i++) {
const csp_entry *entry = &policy->entries[i];
for (size_t j = 0; j < entry->source_count; j++) {
char *s = csp_normalize_source(entry->sources[j]);
if (s == NULL) continue;
score -= csp_score_penalty(s);
free(s);
}
}
if (score < 0) score = 0;
if (score > 100) score = 100;
return score;
}
/* -------------------------------------------------------------------------
* Demo
* ------------------------------------------------------------------------- */
int main(void)
{
static const char *const script_sources[] = {
"'self'", "'unsafe-inline'", "https://cdn.example.com", NULL
};
csp_policy policy;
csp_issue_list issues;
char *header;
csp_policy_init(&policy);
csp_policy_set(&policy, "default-src", CSP_SRC_SELF);
csp_policy_set(&policy, "script-src", script_sources);
csp_policy_set(&policy, "object-src", CSP_SRC_NONE);
csp_policy_set(&policy, "upgrade-insecure-requests", CSP_SRC_EMPTY);
header = csp_build(&policy);
printf("header: %s\n", (header != NULL) ? header : "(allocation failed)");
printf("score: %d/100\n", csp_security_score(&policy));
if (csp_validate(&policy, &issues)) {
for (size_t i = 0; i < issues.count; i++)
printf("issue: %s\n", issues.items[i].message);
csp_issue_list_free(&issues);
}
free(header);
csp_policy_free(&policy);
/* Round-trip a pasted header line. */
if (csp_parse("Content-Security-Policy: default-src 'self'; img-src *",
&policy)) {
char *rebuilt = csp_build(&policy);
printf("parsed: %s\n", (rebuilt != NULL) ? rebuilt : "(allocation failed)");
free(rebuilt);
csp_policy_free(&policy);
}
(void)CSP_COMMON_SOURCES;
return 0;
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →