Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-lions-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mcansh/http-helmet": patch
---

Add `removeInsecureHeaders` to remove server-identifying response headers maintained by the OWASP Secure Headers Project.
153 changes: 153 additions & 0 deletions .github/workflows/update-removal-headers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
name: 🛡️ Update removal headers

on:
schedule:
- cron: "17 13 * * 1"
workflow_dispatch:

permissions:
contents: write
pull-requests: write
Comment on lines +8 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope permissions to the job, not the workflow.

contents: write and pull-requests: write are declared at the workflow level. This grants both permissions to every job in the workflow, including any jobs added later, rather than only the update job that needs them. Move permissions under jobs.update and add a short comment on why each scope is needed.

🔒 Proposed fix
-permissions:
-  contents: write
-  pull-requests: write
+permissions:
+  contents: read
 
 concurrency:
   group: update-http-helmet-removal-headers
   cancel-in-progress: false
 
 jobs:
   update:
     name: 🛡️ Update removal headers
     runs-on: ubuntu-latest
     timeout-minutes: 10
+    permissions:
+      contents: write # push the automation branch with the regenerated removal-header list
+      pull-requests: write # open or update the changeset-backed pull request
     steps:

As per the static analysis hints, this triggers excessive-permissions at lines 9-10 and undocumented-permissions at line 9.

🧰 Tools
🪛 zizmor (1.28.0)

[error] 9-9: overly broad permissions (excessive-permissions): contents: write is overly broad at the workflow level

(excessive-permissions)


[error] 10-10: overly broad permissions (excessive-permissions): pull-requests: write is overly broad at the workflow level

(excessive-permissions)


[warning] 9-9: permissions without explanatory comments (undocumented-permissions): needs an explanatory comment

(undocumented-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/update-removal-headers.yml around lines 8 - 10, Move the
workflow-level permissions block into the jobs.update job so contents: write and
pull-requests: write apply only to that job. Add brief comments documenting why
each permission is required, preserving the existing permission scopes and
workflow behavior.

Source: Linters/SAST tools


concurrency:
group: update-http-helmet-removal-headers
cancel-in-progress: false

jobs:
update:
name: 🛡️ Update removal headers
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.repository.default_branch }}
Comment on lines +22 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable credential persistence and configure git auth explicitly for the push.

actions/checkout is not configured with persist-credentials: false, so the checkout token stays available in the job's git configuration for the rest of the run. The script later relies on this persisted credential for the bare git push commands (lines 118-126), since no explicit git authentication is set up otherwise. Set persist-credentials: false on checkout and configure git authentication explicitly via gh auth setup-git, which uses the GH_TOKEN already exported for the script step.

🔒 Proposed fix
       - name: ⬇️ Checkout repo
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
         with:
           fetch-depth: 0
+          persist-credentials: false
           ref: ${{ github.event.repository.default_branch }}
         run: |
           set -euo pipefail
 
+          gh auth setup-git
+
           response_file=$(mktemp)

As per the static analysis hints, this triggers artipacked (credential persistence through GitHub Actions checkout) at lines 22-26.

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 22-26: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/update-removal-headers.yml around lines 22 - 26, Update
the checkout step using actions/checkout to set persist-credentials to false,
then add explicit Git authentication with gh auth setup-git before the later
bare git push commands in the script, using the existing GH_TOKEN environment
configuration.

Source: Linters/SAST tools


- name: 🛡️ Update removal headers and pull request
shell: bash
env:
BASE_BRANCH: ${{ github.event.repository.default_branch }}
CHANGESET_PATH: .changeset/update-http-helmet-removal-headers.md
GH_TOKEN: ${{ github.token }}
OUTPUT_PATH: packages/http-helmet/src/removal-headers.ts
SOURCE_URL: https://raw.githubusercontent.com/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_remove.json
UPDATE_BRANCH: automation/update-removal-headers
run: |
set -euo pipefail

response_file=$(mktemp)
trap 'rm -f "$response_file"' EXIT

curl \
--fail \
--location \
--max-filesize 1048576 \
--max-time 30 \
--proto '=https' \
--proto-redir '=https' \
--retry 3 \
--show-error \
--silent \
--tlsv1.2 \
"$SOURCE_URL" \
--output "$response_file"

response_size=$(wc -c < "$response_file")
if (( response_size > 1048576 )); then
echo "OWASP response exceeded the 1 MiB limit" >&2
exit 1
fi

jq --exit-status '
.headers
| type == "array"
and length > 0
and length <= 1000
and (unique | length) == length
and all(.[];
if type == "string" then
length > 0
and length <= 256
and test("\\A[!#$%&\u0027*+.^_`|~0-9A-Za-z-]+\\z")
else
false
end
)
' "$response_file" > /dev/null
Comment on lines +63 to +78

remote_branch_sha=""
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null 2>&1; then
git fetch \
--no-tags \
origin \
"+refs/heads/$UPDATE_BRANCH:refs/remotes/origin/$UPDATE_BRANCH"
remote_branch_sha=$(git rev-parse "refs/remotes/origin/$UPDATE_BRANCH")
fi

git switch \
--force-create "$UPDATE_BRANCH" \
"origin/$BASE_BRANCH"

{
printf 'export const removalHeaders = [\n'
jq --raw-output '.headers[] | " \(tojson),"' "$response_file"
printf '];\n'
} > "$OUTPUT_PATH"

if [[ -z "$(git status --porcelain -- "$OUTPUT_PATH")" ]]; then
echo "Removal headers are already up to date"
exit 0
fi

printf '%s\n' \
'---' \
'"@mcansh/http-helmet": patch' \
'---' \
'' \
'Update the insecure response headers list from the OWASP Secure Headers Project.' \
> "$CHANGESET_PATH"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$CHANGESET_PATH" "$OUTPUT_PATH"
git commit -m "chore(http-helmet): update removal headers"

if [[ -n "$remote_branch_sha" ]]; then
git push \
--force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_branch_sha" \
origin \
"HEAD:refs/heads/$UPDATE_BRANCH"
else
git push \
--set-upstream origin \
"HEAD:refs/heads/$UPDATE_BRANCH"
fi

existing_pr=$(gh pr list \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--head "$UPDATE_BRANCH" \
--state open \
--json number \
--jq '.[0].number // empty')

if [[ -n "$existing_pr" ]]; then
gh pr view "$existing_pr" \
--repo "$GITHUB_REPOSITORY" \
--json url \
--jq '"Updated pull request: \(.url)"'
exit 0
fi

printf -v pr_body '%s\n\n%s' \
'Updates `packages/http-helmet/src/removal-headers.ts` from the OWASP Secure Headers Project.' \
'Generated automatically by `.github/workflows/update-removal-headers.yml`.'

gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--head "$UPDATE_BRANCH" \
--title "chore(http-helmet): update removal headers" \
--body "$pr_body"
6 changes: 5 additions & 1 deletion apps/http-helmet/react-router-v7/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createNonce,
createSecureHeaders,
mergeHeaders,
removeInsecureHeaders,
} from "@mcansh/http-helmet";
import { NonceProvider } from "@mcansh/http-helmet/react";
import { createReadableStreamFromReadable } from "@react-router/node";
Expand Down Expand Up @@ -54,7 +55,10 @@ export default function handleRequest(

resolve(
new Response(stream, {
headers: mergeHeaders(responseHeaders, secureHeaders),
headers: mergeHeaders(
removeInsecureHeaders(responseHeaders),
secureHeaders,
),
status: responseStatusCode,
}),
);
Expand Down
17 changes: 17 additions & 0 deletions packages/http-helmet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,20 @@ server.listen(3000);

console.log("✅ app ready: http://localhost:3000");
```

## Remove Insecure Headers

`removeInsecureHeaders` returns a copy of a `Headers` object with every
server-identifying, framework, and diagnostic header in the [OWASP Secure
Headers Project](https://owasp.org/www-project-secure-headers/) list removed,
leaving the original untouched.

```js
import { removeInsecureHeaders } from "@mcansh/http-helmet";

let headers = removeInsecureHeaders(responseHeaders);
Comment on lines +77 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Define responseHeaders in the example.

The snippet uses responseHeaders without declaring it. Add a Headers instance or show the call within a complete response-handling example so users can run the documented code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/http-helmet/README.md` around lines 77 - 80, Update the README
example around removeInsecureHeaders to define responseHeaders before use,
preferably by initializing it as a Headers instance, so the snippet is
self-contained and runnable.

```

This is opt-in, so existing behavior is unchanged. Every entry in the list is
removed — there's no per-header allowlist — so don't use it if your app relies
on a removed header like `X-B3-*` or `X-Datadog-*` for observability.
1 change: 1 addition & 0 deletions packages/http-helmet/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
WASM_UNSAFE_EVAL,
createNonce,
mergeHeaders,
removeInsecureHeaders,
} from "./utils";

export {
Expand Down
89 changes: 89 additions & 0 deletions packages/http-helmet/src/removal-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export const removalHeaders = [
"$wsep",
"Host-Header",
"K-Proxy-Request",
"Liferay-Portal",
"OracleCommerceCloud-Version",
"Pega-Host",
"Powered-By",
"Product",
"Server",
"SourceMap",
"X-AspNet-Version",
"X-AspNetMvc-Version",
"X-Atmosphere-error",
"X-Atmosphere-first-request",
"X-Atmosphere-tracking-id",
"X-B3-ParentSpanId",
"X-B3-Sampled",
"X-B3-SpanId",
"X-B3-TraceId",
"X-BEServer",
"X-Backside-Transport",
"X-CF-Powered-By",
"X-CMS",
"X-CalculatedBETarget",
"X-Cocoon-Version",
"X-Content-Encoded-By",
"X-Datadog-Origin",
"X-Datadog-Parent-Id",
"X-Datadog-Sampling-Priority",
"X-Datadog-Tags",
"X-Datadog-Trace-Id",
"X-DiagInfo",
"X-Envoy-Attempt-Count",
"X-Envoy-External-Address",
"X-Envoy-Internal",
"X-Envoy-Original-Dst-Host",
"X-Envoy-Upstream-Service-Time",
"X-FEServer",
"X-Framework",
"X-Generated-By",
"X-Generator",
"X-Gitlab-Meta",
"X-Jitsi-Release",
"X-Joomla-Version",
"X-Kong-Admin-Latency",
"X-Kong-Client-Latency",
"X-Kong-Proxy-Latency",
"X-Kong-Request-Id",
"X-Kong-Response-Latency",
"X-Kong-Third-Party-Latency",
"X-Kong-Total-Latency",
"X-Kong-Upstream-Latency",
"X-Kong-Upstream-Status",
"X-Kubernetes-PF-FlowSchema-UI",
"X-Kubernetes-PF-PriorityLevel-UID",
"X-LiteSpeed-Cache",
"X-LiteSpeed-Purge",
"X-LiteSpeed-Tag",
"X-LiteSpeed-Vary",
"X-Litespeed-Cache-Control",
"X-Mod-Pagespeed",
"X-Nextjs-Cache",
"X-Nextjs-Matched-Path",
"X-Nextjs-Page",
"X-Nextjs-Redirect",
"X-OWA-Version",
"X-Old-Content-Length",
"X-OneAgent-JS-Injection",
"X-Page-Speed",
"X-Php-Version",
"X-Powered-By",
"X-Powered-By-Plesk",
"X-Powered-CMS",
"X-Redirect-By",
"X-Server-Powered-By",
"X-SourceFiles",
"X-SourceMap",
"X-Turbo-Charged-By",
"X-Tyk-Trace-Id",
"X-Umbraco-Version",
"X-Varnish-Backend",
"X-Varnish-Server",
"X-Woodpecker-Version",
"X-dtAgentId",
"X-dtHealthCheck",
"X-dtInjectedServlet",
"X-ruxit-JS-Agent",
];
47 changes: 47 additions & 0 deletions packages/http-helmet/src/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { removalHeaders } from "./removal-headers.js";
import { removeInsecureHeaders } from "./utils.js";

describe("removeInsecureHeaders", () => {
it("removes every configured insecure header", () => {
let responseHeaders = new Headers();

for (let header of removalHeaders) {
responseHeaders.set(header, "exposed");
}

let headers = removeInsecureHeaders(responseHeaders);
let remainingInsecureHeaders = removalHeaders.filter((header) =>
headers.has(header),
);

expect(removalHeaders.length).toBeGreaterThan(0);
expect(remainingInsecureHeaders).toStrictEqual([]);
});

it("preserves headers that are not configured for removal", () => {
let responseHeaders = new Headers({
"Cache-Control": "max-age=60",
"Content-Type": "text/html; charset=utf-8",
Server: "example",
});

let headers = removeInsecureHeaders(responseHeaders);

expect(headers.get("Cache-Control")).toBe("max-age=60");
expect(headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(headers.has("Server")).toBe(false);
});

it("does not mutate the original headers", () => {
let responseHeaders = new Headers({
Server: "example",
});

let headers = removeInsecureHeaders(responseHeaders);

expect(headers).not.toBe(responseHeaders);
expect(headers.has("Server")).toBe(false);
expect(responseHeaders.get("Server")).toBe("example");
});
});
12 changes: 12 additions & 0 deletions packages/http-helmet/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { removalHeaders } from "./removal-headers.js";

export function isQuoted(value: string): boolean {
return /^".*"$/.test(value);
}
Expand Down Expand Up @@ -65,3 +67,13 @@ export function mergeHeaders(...sources: HeadersInit[]): Headers {
export function createNonce(): string {
return Buffer.from(crypto.randomUUID()).toString("base64");
}

export function removeInsecureHeaders(responseHeaders: Headers) {
const headers = new Headers(responseHeaders);

for (const key of removalHeaders) {
headers.delete(key);
}

return headers;
}
Comment on lines +71 to +79
Loading