Skip to content

Conversation

@josteink
Copy link

@josteink josteink commented Dec 16, 2025

  • This PR is not from my main or master branch 💩, but a separate branch ✅
  • Each commit has a valid ✒️ Signed-off-by: <my@email.address> row (via git commit --signoff)
  • Each commit and PR title has a valid 📝 <package name>: title first line subject for packages
  • Incremented 🆙 any PKG_VERSION in the Makefile
  • Tested on: (Atheros AR9132 rev 2, 25.12.0-RC1, Chromium) ✅
  • ( Preferred ) Mention: @josteink
  • ( Preferred ) Screenshot or mp4 of changes:
image image

This PR adds support for Content Security Policy to Luci Web.

Content Security Policy is a very effective security hardening for browser-based applications:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP

CSP for OpenWRT/Luci is implemented in 4 different modes:

  • None (Disabled - least secure) - default
  • Permissive (More secure. Should permit extensive customization)
  • Strict (Most secure. Only allow what is required by OpenWRT/Luci by default) - block everything else
  • Custom - allow user to define own CSP policy

This option is stored in /etc/config/uhttpd and also manageable via the Luci Web UI under "System" -> "Administration" -> "uHTTPd" -> "HTTP(s) Acces" -> "General settings".

@github-actions

This comment has been minimized.

@efahl
Copy link
Contributor

efahl commented Dec 16, 2025

fetch/etc from own origin/IP, and nowhere else.

I didn't try it, but does this prevent "System -> Backup/Flash" from downloading .img files directly? How about Attended Sysupgrade, which does a bunch of POST and GET fetches to do its upgrades?

You can see some of this at

@josteink
Copy link
Author

josteink commented Dec 16, 2025

Good catch. Attended sysupgrade fails, and will need adjustment to the CSP headers. I'll add rules as required to ensure it passes.

Connecting to 'https://sysupgrade.openwrt.org/api/v1/revision/25.12-SNAPSHOT/ath79/generic?1765915536852' violates the following Content Security Policy directive: "default-src 'self'".
Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.

I'm not sure what to test in System -> Backup/Flash. I don't see anything there causing (or which should cause) HTTP requests anywhere?

@efahl
Copy link
Contributor

efahl commented Dec 16, 2025

I'm not sure what to test in System -> Backup/Flash. I don't see anything there causing (or which should cause) HTTP requests anywhere?

Oh, ok, I didn't dig into it, I thought it did a .get when you uploaded the Flash image...

@josteink
Copy link
Author

Adding https://sysupgrade.openwrt.org fixes the default//OOB config, but I see that it will break with custom configurations:

image

I guess probably the simplest, most pragmatic option is just to set connect-src to allow anything. The CSP will still be a major improvement over what is already there (which is no hardening at all).

@josteink
Copy link
Author

With the testing I've been able to to, it seems like attended sysupgrade now works as well!

@efahl
Copy link
Contributor

efahl commented Dec 16, 2025

Yeah, changing the upstream url, or adding one to "Rebuilders" complicates it. Could the ASU code add exceptions somehow for the duration of its use???

You can use https://sysupgrade.guerra24.net as primary server, or added as a rebuilder, if you felt like testing that...

@josteink
Copy link
Author

josteink commented Dec 16, 2025

I went for the pragmatic and simple solution: allow fetch from any HTTPS-host.

It’s not perfect, but it’s still clearly a major improvement over what is there today due to the restricted script and default permissions.

In absence of any other areas to validate/test, I don’t plan any other updates to this PR.

@efahl
Copy link
Contributor

efahl commented Dec 16, 2025

Hmm, maybe another one. I have luci-app-statistics installed on one of my APs, and am getting this when I try to view the generated images:

Content-Security-Policy: The page’s settings blocked the loading of a resource (img-src) at blob:https://wap01/0bd0c7d6-fc6c-4917-a773-8847d822c46e because it violates the following directive: “img-src 'self' data:”

Hacked it up to contain blob: and it works:

507                 if (!this.headers?.['Content-Security-Policy'])
508                         this.header('Content-Security-Policy',
509                         "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'trusted-types-eval';"
510                         + "img-src 'self' data: blob:;"
511                         + "style-src 'self' 'unsafe-inline';"
512                         + "connect-src 'self' https://*;");

@efahl
Copy link
Contributor

efahl commented Dec 16, 2025

And firefox says

Content-Security-Policy: Couldn’t parse invalid host 'trusted-types-eval'

But how do browsers deal with these "bad" ones? Just ignore them? In which case, who cares if it "errors"?

That one specifically seems to have very narrow support, but if it is supported, seems like it should be there.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src#browser_compatibility

@systemcrash
Copy link
Contributor

For reasons you're now discovering, this is why we've not bothered with extended security measures like this, since most of the customisation people do or use depends on the freedom to do so. There are perhaps only a few apps which call to external resources like this, and the sources are entirely verifiable. Introducing this change will likely break some functionality somewhere... :/

@josteink
Copy link
Author

josteink commented Dec 16, 2025

Hacked it up to contain blob: and it works:

Nice. That sounds like a reasonable addition as well.

But how do browsers deal with these "bad" ones? Just ignore them? In which case, who cares if it "errors"?

They ignore them. They can’t apply rules/restrictions they don’t know about.

This one in particular is required by Safari and does no harm elsewhere as far as I’ve tested.

@josteink
Copy link
Author

josteink commented Dec 16, 2025

For reasons you're now discovering, this is why we've not bothered with extended security measures like this, since most of the customisation people do or use depends on the freedom to do so.

These are IMO simple issues to work around or loosen up.

If people are qualified to customize these things, they’re probably qualified to update or remove this header too?

There are perhaps only a few apps which call to external resources like this

People might get malicious browser extensions which wants to inject scripts into your router interface.

This would help protect against that to a certain extent.

and the sources are entirely verifiable. Introducing this change will likely break some functionality somewhere... :/

IMO script-src is the most important one to get in and the rest can be loosened where there’s any plausible reason for doing so.

@josteink
Copy link
Author

From a brainstorming POV, would it be possible to implement this as a luci-app? luci-app-csp?

Then those who want it can install it and it could contain a config-pane (with these defaults) which injects the headers into http.uc?

@Ramon00
Copy link
Contributor

Ramon00 commented Dec 16, 2025

It could be made configurable indeed. Add a option somewhere maybe in the system menu or something, different degrees of allowing things. Add a help text to explain.

@mdevolde
Copy link
Contributor

mdevolde commented Dec 16, 2025

could contain a config-pane

Or just a text area to write your CSP from start to finish, which is injected into http.uc (this way, anyone who wants to apply a CSP can completely customise their CSP according to their config).

So, an interface to do it properly is available, without having the end user tinkering with ucode files, but the CSP must be written by the end user.

So, nothing broken by installing the app.

@josteink
Copy link
Author

Or just a text area to write your CSP from start to finish, which is injected into http.uc (this way, anyone who wants to apply a CSP can completely customise their CSP according to their config).

This sounds more viable from a practical point of view, and for my first Luci submission 😄

Provide a reasonable default, and allow whatever the user wants. If non-empty, use as a CSP headers. If empty, don't provide a CSP header.

I think that's OK for a version 1 implementation, isn't it?

@Ramon00
Copy link
Contributor

Ramon00 commented Dec 17, 2025

I think that's OK for a version 1 implementation, isn't it?

Depends on the default, if the default is empty then this is NOK (people would not know what to fill in).

I do like the idea of user defined.

I would still go for drop down, 1) none 2) loose 3) tight 4) user defined. {previewing the new header) This is most user friendly and will serve everybody. I'm guessing 99% of the people will use 1), 2) or 3).

Do not choose for the easy solution just because you do not have the experience to implement, there are plenty of people willing to help out.

I do wonder what the default should be, 1) or 2) or 3)...

@josteink
Copy link
Author

josteink commented Dec 17, 2025

I've added and pushed a first iteration of using UCI for settings and a minimal UI for it.

  1. checkbox to enable/disable
  2. default policy as derived from the discussion in this PR.

Do we have any good examples in the luci interface of a dropdown which automatically populates the value of an accompagnying text-field?

@github-actions

This comment has been minimized.

@Ramon00
Copy link
Contributor

Ramon00 commented Dec 18, 2025

Do we have any good examples in the luci interface of a dropdown which automatically populates the value of an accompagnying text-field?

Yes there should be plenty around, for example look at luci-app-usteer which I made.

o.default = o.enabled;
o.rmempty = false;

o = s.taboption('general', form.Value, 'csp_policy', _('CSP Policy'), _('Custom Content-Security-Policy header value. Leave empty to use the default policy.'));
Copy link
Contributor

Choose a reason for hiding this comment

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

Use a textbox.

Copy link
Author

Choose a reason for hiding this comment

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

This is the first time I'm trying to build something in the Luci UI. From what I can tell, this is how most textboxes in the uhttpd.js file is defined.

Could you be more concrete wrt to what you think should be "textboxed" here?

@Ramon00
Copy link
Contributor

Ramon00 commented Dec 20, 2025

CSP is a browser-only technology. Forbidding firmware-uploads from the main luci web interface might still permit it from other origins unless it is explicitly forbidden on the server-side.

nevermind, you do not get what i mean

@josteink josteink force-pushed the feature/csp branch 2 times, most recently from 3074e8f to ad1ec17 Compare December 20, 2025 20:28
@josteink
Copy link
Author

Latest changes used RichListValue and enables some more descriptive content for those not 100% up to speed on CSP.

image

Looking pretty good, if I have to say so myself!

@github-actions

This comment has been minimized.

@josteink josteink force-pushed the feature/csp branch 2 times, most recently from e55cbff to 3a5fea1 Compare December 20, 2025 20:44
csp_mode_option.rmempty = false;

var csp_policy_option = s.taboption('general', form.Value, 'csp_policy', _('Custom CSP Policy String'), _('The Content-Security-Policy header-value used in custom-mode.') + "<br />" + _(' WARNING: Wrong values for this setting can render the web-UI inaccessible and require recovery by SSH.'));
csp_policy_option.default = 'default-src \'none\'; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' \'trusted-types-eval\'; img-src \'self\' data: blob:; style-src \'self\' \'unsafe-inline\'; connect-src \'self\' https://sysupgrade.openwrt.org;'
Copy link
Contributor

Choose a reason for hiding this comment

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

@systemcrash Wouldn't it be more appropriate, for here and for http.uc, to create a dedicated section in /etc/config/uhttpd where we put the different CSP default values, rather than hardcoding them in the js and ucode?

Copy link
Author

Choose a reason for hiding this comment

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

Yes and no?

On the one hand I'd like to avoid code-repetition, embrace modularity and extensibility. That talks in favour of your suggestions.

On the other hand I'd like to make it simple to use. Not just now, but over time.

I think "strict" and "permissive" are tightly connected to the OpenWRT codebase and release itself. What is possible and/or required for "strict" may change over time. If we store this in /etc/config/uhttpd somewhere, user's will typically choose to preserve these settings when doing upgrades.

If the default values for those presets have changed between releases, that will be a upgrade which can fail in unepexted/undesirable ways. Keeping these presets fixed in code avoids that.

And I think that is a bigger problem/risk than these presets being hardcoded in code. That's an argument for keeping things as is.

I'm leaning more towards that direction, but I'm not 100% set on this if someone can explain a way we can make it "upgrade-safe".

Copy link
Contributor

Choose a reason for hiding this comment

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

@josteink I don't understand your concerns. It doesn't change anything from the user's point of view, the strings will just be stored in the httpd config file, and not in the code any more.
It will be easier for developers to maintain, and it doesn't change anything from the user's point of view.
The section containing them will not be editable from JS.
I am waiting for the opinion of a maintainer, in case there are any contraindications.

Copy link
Author

Choose a reason for hiding this comment

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

Just chiming in with a concrete example while awaiting the @systemcrash 's opinion:

  1. The user installs OpenWRT release with CSP support (lets say 25.12.1)
  2. CSP presets/profiles are stored in /etc/config/uhttpd - based on that release
  3. A new Luci/OpenWRT release comes out later (lets says 26.10.0), with some different JS in the browser which requires permissions not granted by the existing CSP preset/profiles. We update the profiles as well to ensure it works.
  4. User upgrades to 26.12.0 and checks the option "keep existing config".
  5. He will get the config file from 25.12.1 with the old presets, but the Luci code which requires the 26.10.0 presets.
  6. Luci may fail to load as a result. User may be unhappy. Depending on his ability to SSH in and edit the relevant config files, he may just say the upgrade "bricked" his router.

That's the scenario I am worried about.

If I'm worrying about something which is trivial to solve, by all means, I'm happy to go that route. Just let me know how!

Just in case it's not obvious: This is my first OpenWRT contribution and I'm fairly new to the source-code 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

@josteink I understand your concern better now, I don't know if there's a way to remedy this.
What's unfortunate at the moment, from a maintainability standpoint, is the repetition of the "strict" CSP in JS and ucode, as well as their decentralisation (not all written CSP at the same place).

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe a script in /etc/uci-defaults/ could be a solution, I'm curious to hear a maintainer's opinion on this matter.

Copy link
Author

@josteink josteink Dec 20, 2025

Choose a reason for hiding this comment

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

Custom is going to be custom, no matter what, so that's obviously something which should go in /etc/config/uhttpd as it does now.

is the repetition of the "strict" CSP in JS and ucode

Technically speaking the CSP in the JS is not "strict" but a default-value for "custom", as a convenience, based on "strict". I thought it would be a "nice-to-have" sort of thing, but if it makes things complicated/decentralized, we could just remove the default as well, leaving the only 2 profiles in the ucode part of the codebase.

Either way I'm curious to hear about other, possibly better solutions.

Copy link
Contributor

Choose a reason for hiding this comment

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

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@systemcrash
Copy link
Contributor

The problems I see at the moment are as follows:

  • settings in an app that might not be installed

This potentially introduces new behaviour, yet does not surface a control element. The behaviour is in base, but the config element is in a separate app. luci-app-uhttpd seems like a good place for it. But luci-app-uhttpd configures uhttpd behaviour. This change is about controlling the front-end behaviour. It seems to me the better placement would be under system->admin->http, which is about the only decent place in base.

This addition correctly 'fails open' - if there is no config present, it remains in a disabled state. You might want to flag that in the GUI - that disabled is the default state.

It also might be a bit inconsistent, but use " to wrap the string, and ' to quote the elements in it.

What settings can render the UI inaccessible?

@josteink
Copy link
Author

josteink commented Dec 28, 2025

The problems I see at the moment are as follows:

  • settings in an app that might not be installed

I may be misunderstanding something, but the file I modified (uhttpd.js) for the settings is where these kind of HTTP controls used to be already.

This potentially introduces new behaviour, yet does not surface a control element.

This only introduces a new behaviour, if configured. What control elements are you looking for, beside the UI provided?

The behaviour is in base, but the config element is in a separate app. luci-app-uhttpd seems like a good place for it. But luci-app-uhttpd configures uhttpd behaviour. This change is about controlling the front-end behaviour. It seems to me the better placement would be under system->admin->http, which is about the only decent place in base.

The setting you are referring to seems (to my untrained eye) to be uhttpd.js. Check the URL for the admin-page you are referring to:

image

When grepping the source for "Redirect to HTTPS", this setting is only found in uhttpd.js.

% git grep "Redirect all HTTP to HTTPS" | grep .js
applications/luci-app-uhttpd/htdocs/luci-static/resources/view/uhttpd/uhttpd.js:		o = s.taboption('general', form.Flag, 'redirect_https', _('Redirect all HTTP to HTTPS'));

This addition correctly 'fails open' - if there is no config present, it remains in a disabled state. You might want to flag that in the GUI - that disabled is the default state.

Good idea. I'll make it so.

It also might be a bit inconsistent, but use " to wrap the string, and ' to quote the elements in it.

Fine by me 😄

What settings can render the UI inaccessible?

Only setting which can render the UI inaccessible is "custom" with a bad policy which excludes required JS scripts, in the area clearly marked as "experts only", and with a safe default provided.

Edit: I see my grep was incorrect and there is a different module I can (should?) insert these changes into.

I'll make it so too!

@systemcrash : I assume modules/luci-mod-system/htdocs/luci-static/resources/view/system/uhttpd.js is the right file then, to avoid dependency on non-default app? :)

Or should I keep it in both?

@josteink
Copy link
Author

All comments should in theory be resolved? Anything else I should change? 😄

@systemcrash
Copy link
Contributor

If you're aware of any settings already which might be problematic, we might surface a time limited warning when those are entered. I propose a .validate that returns true, but raises a ui.addTimeLimitedNotification(...), if you know a combo which can do this.

I suggest that such a long string warrants a textbox, so everything is visible at once.

If the setting is available in both places (app and mod system), that's OK.

@josteink
Copy link
Author

If you're aware of any settings already which might be problematic, we might surface a time limited warning when those are entered.

No settings are by themselves "problematic" more so than the others.

  • None. "Problematic" because it offers no browser-based security against external abuse, and you don't want your router to be insecure.
  • Permissive. Works with any default OpenWRT install. Could be seen as "problematic" because it permits more attack-vectors than strictly needed (like custom attended sysupgrade feeds), unless the user has a specific need to loosen up security.
  • Strict. Works with any default OpenWRT install. Could be "problematic" in the sense that it works now, but you install some other luci-apps and for whatever reason they don't seem to be working (only showing CSP warnings in the browser console) somewhat obscuring the cause of the failure. (This is what I will be running 100% of the time on all of my devices)
  • Custom. "Problematic" because there's no way for us to know up front if a policy is going to work or not. This will be 100% dependent on the users deployment and customizations.

Right now, if you do a default OpenWRT install on any device, apply any of the suggested modes (including custom with default value policy) and click "Save & Apply", everything default in OpenWRT will keep on working 100%. You will not be prevented from entering the same admin-section to change the setting later.

Does that clear up the expected behaviour sufficiently?

I propose a .validate that returns true, but raises a ui.addTimeLimitedNotification(...), if you know a combo which can do this.

The only place where there is a real risk is when using "custom" mode. We cannot validate up front if that will be successful or not.

So I guess that means .validate() should return true if current value is "custom" ?

@josteink
Copy link
Author

josteink commented Dec 29, 2025

@systemcrash : I tried the following code to just to test. It works, kinda, but I always get the validate-handler invoked twice on changes, leading to the ui popup to appear twice as well.

		csp_mode_option.validate = function(section_id, value) {
			// triggers UI warning when using custom-mode with UI timer based fallback
			if (value === "custom")
			{
			  ui.addTimeLimitedNotification(_('Danger zone'), E('p', _('Custom CSP header will be used as is. If deemed invalid by the browser, you will need to use SSH to reset the value in /etc/config/uhttpd.')), 60000, 'warning');
			}
			return true;
		};
image

Is this a general luci problem, or is it anything else I'm doing wrong? 😄

@systemcrash
Copy link
Contributor

No, validate is triggered on the cfgvalue and load and I think another one of the function calls, IIRC. :/

That custom string? I can't see anything. That's why it should be a text box.

What I mean are policies like the following:

Content-Security-Policy:
  default-src 'none';
  script-src 'none';
  style-src 'none';
  img-src 'none';
  font-src 'none';
  connect-src 'none';
  frame-src 'none';

So scanning for the key-word none is probably sufficient to trigger a warning.

@josteink
Copy link
Author

josteink commented Dec 30, 2025

No, validate is triggered on the cfgvalue and load and I think another one of the function calls, IIRC. :/

Is there a convention somewhere on how to avoid double warnings then, or should I just leave it?

That custom string? I can't see anything. That's why it should be a text box.

What's the best/right way to do that? I don't seen any Form.TextBox type to use here.

So scanning for the key-word none is probably sufficient to trigger a warning.

To be absoultely clear, the default-value for custom (based on strict) intentionally defaults to none unless another policy is set (default-src 'none').

That is, you use the other policies to whitelist only the things you know you need. So this warning will also trigger for the default value.

Also, for instance, for script-src it's not enough to just say "from this origin", you also have to allow what type of unsafe behaviours (like eval) you want to allow.

Creating a CSP-validator which is meaningful is going to be a much bigger job than just getting the basic settings in there, and it will be 10x more complicated than the actual feature already proposed.

If someone later feels CSP is great, and they want a better interactive editor for that... I would delegate that responsibility to a separate, installable luci-app, like we have luci-app-uhttpd for exactly the same reasons for more of the "advanced" uhttpd settings.

It is my opinion that this PR, as is, will provide a substantial security improvement to LUCI compared to how it was before.

Let's resolve the above issues, and leave the CSP-editor/validator aspects to eventually be solved later, if there turns out to be a need?

@systemcrash
Copy link
Contributor

Is there a convention somewhere on how to avoid double warnings then, or should I just leave it?

There is, IIRC. I just can't remember it now... let me have a think.

Ah, sorry - take a look at: https://openwrt.github.io/luci/jsapi/LuCI.form.TextValue.html

There are some in use in the code-base. Take a peek at how others are used.

That is, you use the other policies to whitelist only the things you know you need.

I took a look at MDN which says:

the single value 'none', indicating that the specific resource type should be completely blocked

So we can at least add a note about that in the GUI: e.g. 'none' completely blocks the specific resource type, and if the user saws off the branch they're standing on, it's on them :)

This commit adds support for Content Security Policy to Luci Web.

Content Security Policy is a very effective security hardenining
for browser-based applications.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP

The CSP can be enabled/disabled and customized via uci.

The default "strict" provided CSP effectively limits the Luci web-app
to only allow:

- scripts from its own origin/IP, and nowhere else.
- css from its own origin/IP, and nowhere else.
- images from its own origin/IP, and nowhere else.
- fetch/etc from own origin/IP. and sysupgrade.
  nowhere else.
- (etc)

Softening exceptions:

- Allow javascript to use inline scripts, eval() and trusted types.
- Allow CSS to use inline styles.
- Allow IMG-tags with data: and blob:-type URLs.
- Allow XHR/fetch for any https:// url - required to support attended
  sysupgrade.

trusted-types-eval is required by Safari on MacOS, supported in
Firefox Nightly, and ignored in Chrome/Chromium.

This CSP should prevent almost any kind of XSS or script-injection
while at the same time allow Luci to perform any task it needs to do.

The default "permissive" allows more https:// sources.

"Custom" is defined by the user entirely.

Signed-off-by: Jostein Kjønigsen <jostein@kjonigsen.net>
- add dropdown to select CSP-mode
- add textbox to configure custom CSP policy

Signed-off-by: Jostein Kjønigsen <jostein@kjonigsen.net>
- add dropdown to select CSP-mode
- add textbox to configure custom CSP policy

Signed-off-by: Jostein Kjønigsen <jostein@kjonigsen.net>
@josteink
Copy link
Author

josteink commented Jan 1, 2026

No new changes in latest pull. Have been busy during Christmas as everyone else 😄

Just did a rebase on upstream git master to ensure I'm not walking into any merge-conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants